update user login baru dan hakakses

This commit is contained in:
Fanrouver
2025-12-18 15:11:41 +07:00
parent dfcd59481c
commit c95da96017
20 changed files with 2564 additions and 404 deletions
+70
View File
@@ -0,0 +1,70 @@
# Placeholder API Permission
## Deskripsi
Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia.
## Cara Menggunakan
### 1. Menggunakan Placeholder API secara Default
Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.150.131:8089/api/v1/permission`) tidak dapat diakses.
### 2. Memaksa Menggunakan Placeholder API
Tambahkan query parameter `usePlaceholder=true` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true
```
### 3. Menonaktifkan Placeholder API
Tambahkan query parameter `usePlaceholder=false` pada request:
```
GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false
```
## Data Placeholder yang Tersedia
### Role: superadmin, Group: STIM
Data placeholder mengembalikan 5 permission items:
- Halaman Utama (read: true, active: true)
- Pengaturan (read: true, active: true)
- Halaman (read: true, active: true, disable: true)
- Dashboard (read: true, active: true, disable: true)
## Mapping Pagename ke Menu Sidebar
Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar:
- "Halaman Utama" → "Dashboard"
- "Pengaturan" → "Master Data"
- "Halaman" → "Master Data"
- "Dashboard" → "Dashboard"
## Testing dengan User bayurssa
Untuk testing dengan user email "bayurssa":
1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak
2. Login dengan email "bayurssa" dan password "12345"
3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia
4. Sidebar akan terfilter berdasarkan permissions dari placeholder API
## Mapping Role dan Group
Sistem secara otomatis melakukan normalisasi untuk role dan group:
### Normalisasi Role
- `default-roles-sandbox``superadmin`
- Role lain akan digunakan apa adanya (lowercase)
### Normalisasi Group
- `Instalasi STIM``STIM`
- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM"
- Group lain akan digunakan apa adanya (uppercase)
### Contoh Mapping
- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM`
## Catatan
- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan
- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API
- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API
- Jika backend API juga gagal, sistem akan mengembalikan data kosong
+339 -51
View File
@@ -1,71 +1,161 @@
<template>
<v-card class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
Edit Hak Akses Menu
<div class="text-subtitle-2 text-medium-emphasis mt-1">
<span v-if="localItem.role || localItem.group">
{{ localItem.role }} / {{ localItem.group }}
</span>
<span v-else-if="localItem.namaTipeUser">
{{ localItem.namaTipeUser }}
</span>
</div>
<v-card class="pa-6 rounded-xl elevation-4">
<v-card-title class="d-flex align-center text-h5 font-weight-bold mb-4">
<v-icon icon="mdi-lock-check-outline" class="mr-2 text-primary" size="28"></v-icon>
<span>Edit Hak Akses Menu</span>
</v-card-title>
<v-card-text>
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row v-if="localItem.role || localItem.group" class="mb-4">
<v-col cols="12">
<v-alert type="info" variant="tonal" density="compact">
<strong>Role:</strong> {{ localItem.role }} | <strong>Group:</strong> {{ localItem.group }}
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-4">Hak Akses Menu</v-card-title>
<v-table density="compact" class="elevation-1 rounded-lg">
<v-table density="comfortable" class="elevation-1 rounded-xl hak-akses-table">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Menu</th>
<th class="text-center">Akses</th>
<th class="text-center">Lihat</th>
<th class="text-center">Tambah</th>
<th class="text-center">Edit</th>
<th class="text-center">Hapus</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1 kol-no">No</th>
<th class="text-left text-uppercase font-weight-bold text-grey-darken-1">Menu</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-status">Status</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Akses</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Lihat</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Tambah</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Edit</th>
<th class="text-center text-uppercase font-weight-bold text-grey-darken-1 kol-aksi">Hapus</th>
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
<v-checkbox v-model="menu.canAccess" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canView" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canAdd" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canEdit" hide-details></v-checkbox>
</td>
<td class="text-center">
<v-checkbox v-model="menu.canDelete" hide-details></v-checkbox>
</td>
</tr>
<template v-if="backendPermissions.length > 0">
<tr v-for="(perm, index) in sortedPermissions" :key="perm.id" :class="{ 'bg-grey-lighten-5': perm.level === 2 }">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>
<div :style="{ paddingLeft: perm.level === 2 ? '32px' : '0' }" class="d-flex align-center">
<v-icon v-if="perm.level === 2" icon="mdi-subdirectory-arrow-right" size="small" class="mr-2 text-grey"></v-icon>
<span :class="{ 'font-weight-bold': perm.level === 1 }">{{ perm.pagename }}</span>
<v-chip v-if="perm.level" size="x-small" variant="outlined" class="ml-2" color="grey">
Level {{ perm.level }}
</v-chip>
</div>
</td>
<td class="text-center kol-status">
<v-chip
:color="isPageMapped(perm.pagename) ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ isPageMapped(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.active"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.read"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.create"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.update"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox
v-model="perm.delete"
hide-details
color="primary"
></v-checkbox>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td class="kol-no text-center">{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center kol-status">
<v-chip color="success" size="small" variant="tonal">
Mapped
</v-chip>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAccess" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canView" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canAdd" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canEdit" hide-details color="primary"></v-checkbox>
</div>
</td>
<td class="text-center kol-aksi">
<div class="cek-wrapper">
<v-checkbox v-model="menu.canDelete" hide-details color="primary"></v-checkbox>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-4">
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
rounded="lg"
class="text-capitalize mr-2"
@click="$emit('cancel')"
>
Batal
</v-btn>
<v-btn
color="orange-darken-2"
color="primary"
variant="flat"
class="text-capitalize rounded-lg"
@click="$emit('save', localItem)"
rounded="lg"
class="text-capitalize"
@click="handleSave"
:loading="isSaving"
>
Submit
</v-btn>
@@ -74,7 +164,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
// Define types for better readability and safety
@@ -87,6 +177,21 @@ interface HakAksesMenu {
canDelete: boolean;
}
interface BackendPermissionItem {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface HakAksesData {
id: number;
role?: string;
@@ -100,10 +205,6 @@ const props = defineProps({
item: {
type: Object as () => HakAksesData,
required: true,
// Add custom validator for more robust checks
validator: (value: HakAksesData) => {
return 'hakAksesMenu' in value;
},
},
});
@@ -112,9 +213,128 @@ const emits = defineEmits(['save', 'cancel']);
// Use a local copy to avoid mutating the prop directly
const localItem = ref<HakAksesData>(JSON.parse(JSON.stringify(props.item)));
const backendPermissions = ref<BackendPermissionItem[]>([]);
const isSaving = ref(false);
const navItemsStore = useNavItemsStore();
// Helper function to normalize group name
const normalizeGroup = (group: string): string => {
const normalized = group.trim();
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
if (normalized.toLowerCase().includes('instalasi')) {
const parts = normalized.split(/\s+/);
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
if (stimIndex !== -1) {
return 'STIM';
}
}
// Jika group adalah "Instalasi STIM", return "STIM"
if (normalized.toLowerCase() === 'instalasi stim') {
return 'STIM';
}
return normalized.toUpperCase();
};
// Helper function to normalize role name
const normalizeRole = (role: string): string => {
const normalized = role.toLowerCase().trim();
// Mapping khusus untuk role default
if (normalized === 'default-roles-sandbox') {
return 'superadmin';
}
return normalized;
};
// Fetch permissions from backend API when component mounts
onMounted(async () => {
if (localItem.value.role && localItem.value.group) {
try {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(localItem.value.role);
const normalizedGroup = normalizeGroup(localItem.value.group);
console.log('🔄 Fetching permissions with normalized values:', {
originalRole: localItem.value.role,
normalizedRole,
originalGroup: localItem.value.group,
normalizedGroup,
});
const response = await $fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
// Create a deep copy to avoid mutating the original
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
} else {
console.warn('⚠️ No data received from API or invalid structure');
}
} catch (error) {
console.error('❌ Error fetching permissions:', error);
}
} else {
console.warn('⚠️ Role or group is missing:', {
role: localItem.value.role,
group: localItem.value.group,
});
}
});
// Sort permissions: level 1 first, then level 2 grouped under their parents
const sortedPermissions = computed(() => {
if (backendPermissions.value.length === 0) return [];
const level1 = backendPermissions.value.filter(p => p.level === 1);
const level2 = backendPermissions.value.filter(p => p.level === 2);
// Sort level 1 by sort order
level1.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Sort level 2 by sort order
level2.sort((a, b) => (a.sort || 0) - (b.sort || 0));
// Build result: insert level 2 items after their parent
const result: BackendPermissionItem[] = [];
level1.forEach(parent => {
result.push(parent);
// Add children of this parent
const children = level2.filter(child => child.parent === parent.pagesID);
result.push(...children);
});
// Add any remaining level 2 items that don't have a parent match
const addedChildren = new Set(level2.filter(c => c.parent && level1.some(p => p.pagesID === c.parent)).map(c => c.id));
const remainingChildren = level2.filter(c => !addedChildren.has(c.id));
result.push(...remainingChildren);
return result;
});
// Cek apakah suatu pagename dari backend sudah termapping ke salah satu menu di hakAksesMenu
const isPageMapped = (pagename: string): boolean => {
if (!localItem.value.hakAksesMenu || localItem.value.hakAksesMenu.length === 0) {
return false;
}
const lowerPage = (pagename || '').toLowerCase();
return localItem.value.hakAksesMenu.some((menu) => {
const name = (menu.name || '').toLowerCase();
return (
name === lowerPage ||
name.includes(lowerPage) ||
lowerPage.includes(name)
);
});
};
interface NavItemOrder {
name: string;
children?: NavItemOrder[];
@@ -147,10 +367,60 @@ const orderedMenus = computed(() => {
});
});
// Handle save - convert backend permissions back to menu structure if needed
const handleSave = async () => {
isSaving.value = true;
try {
// If we have backend permissions, we need to map them back to the menu structure
if (backendPermissions.value.length > 0) {
// Update the local item with backend permissions mapped to menu structure
const updatedItem = {
...localItem.value,
backendPermissions: backendPermissions.value,
};
emits('save', updatedItem);
} else {
// Use existing menu structure
emits('save', localItem.value);
}
} finally {
isSaving.value = false;
}
};
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
});
// Re-fetch permissions if role/group changed
if (newItem.role && newItem.group) {
// Normalize role and group before making API call
const normalizedRole = normalizeRole(newItem.role);
const normalizedGroup = normalizeGroup(newItem.group);
console.log('🔄 Re-fetching permissions with normalized values:', {
originalRole: newItem.role,
normalizedRole,
originalGroup: newItem.group,
normalizedGroup,
});
$fetch<any>('/api/permission', {
query: {
roles: normalizedRole,
groups: normalizedGroup,
},
}).then(response => {
if (response && response.data && Array.isArray(response.data)) {
console.log(`✅ Received ${response.data.length} permissions from API`);
backendPermissions.value = response.data.map((perm: BackendPermissionItem) => ({
...perm,
}));
}
}).catch(error => {
console.error('❌ Error fetching permissions:', error);
});
}
}, { deep: true });
</script>
<style scoped>
@@ -166,4 +436,22 @@ watch(() => props.item, (newItem) => {
.v-checkbox :deep(.v-selection-control__input) {
color: #2196F3 !important;
}
.hak-akses-table :deep(.kol-no) {
width: 56px;
}
.hak-akses-table :deep(.kol-aksi) {
width: 90px;
}
.hak-akses-table :deep(.kol-status) {
width: 120px;
}
.cek-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
</style>
+19 -11
View File
@@ -58,22 +58,22 @@
</div>
<v-list density="compact" class="pa-0">
<v-list-item
prepend-icon="mdi-account-circle"
class="px-2 rounded-lg"
link
@click="handleAction('profile')"
>
<v-list-item-title class="text-body-2">Profil</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-cog-outline"
class="px-2 rounded-lg"
link
@click="handleAction('setting')"
>
<v-list-item-title class="text-body-2">Setting</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-help-circle-outline"
class="px-2 rounded-lg"
link
@click="handleAction('help')"
>
<v-list-item-title class="text-body-2">Bantuan</v-list-item-title>
<v-list-item-title class="text-body-2">Pengaturan</v-list-item-title>
</v-list-item>
<v-divider class="my-2"></v-divider>
@@ -101,6 +101,7 @@
<script setup>
import { ref } from 'vue';
import { navigateTo } from '#app';
// --- PROPS & EMITS ---
const props = defineProps({
@@ -139,7 +140,14 @@ const signOut = () => {
const handleAction = (action) => {
console.log(`[PopupSidebar] Action: ${action} triggered.`);
// TODO: Tambahkan logika navigasi/fungsi di sini, misal: router.push('/settings')
switch(action) {
case 'profile':
navigateTo('/Profile/Profil');
break;
case 'setting':
navigateTo('/Profile/Pengaturan');
break;
}
menu.value = false;
};
</script>
+3 -43
View File
@@ -108,7 +108,7 @@
<div class="text-caption font-weight-medium">Pengaturan</div>
</div>
<div
<!-- <div
class="menu-tile rounded-lg pa-4 text-center cursor-pointer"
@click="handleAction('darkMode')"
>
@@ -120,41 +120,7 @@
<div class="text-caption font-weight-medium">
{{ darkMode ? 'Gelap' : 'Terang' }}
</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<!-- Quick Actions -->
<div class="quick-actions">
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('notifications')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-bell-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Notifikasi
</v-list-item-title>
<template v-slot:append>
<v-badge color="orange-darken-2" content="3" inline></v-badge>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-3 py-2 action-item"
link
@click="handleAction('help')"
>
<template v-slot:prepend>
<v-icon size="20" color="blue-darken-1">mdi-help-circle-outline</v-icon>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
Bantuan & Dukungan
</v-list-item-title>
</v-list-item>
</div> -->
</div>
<v-divider class="my-4"></v-divider>
@@ -217,12 +183,6 @@ const handleAction = (action) => {
case 'profile':
navigateTo('/Profile/Profil')
break;
case 'notifications':
navigateTo('/notifications')
break;
case 'help':
navigateTo('/help')
break;
case 'darkMode':
darkMode.value = !darkMode.value;
return;
@@ -350,7 +310,7 @@ const handleAction = (action) => {
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
+40 -7
View File
@@ -22,7 +22,7 @@ import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth";
definePageMeta({
middleware: 'auth'
middleware: ['auth', 'permissions']
})
// State for controlling the sidebar
@@ -98,7 +98,8 @@ const fetchCurrentUserData = async () => {
}
};
// Fetch permissions from backend API
// Fetch permissions from backend API (only for nav filtering, not for saving)
// Saving is now handled by permissions middleware
const fetchPermissionsFromAPI = async () => {
const { roles, groups } = await fetchCurrentUserData();
@@ -125,6 +126,7 @@ const fetchPermissionsFromAPI = async () => {
if (response && response.data && Array.isArray(response.data)) {
apiPermissions.value = response.data;
// Note: Auto-save to allHakAksesData is now handled by permissions middleware
}
} catch (error) {
console.error('Error fetching permissions from API:', error);
@@ -181,20 +183,51 @@ const filteredNavItems = computed(() => {
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
);
// Mapping untuk pagename dari API ke nama menu di sidebar
const pagenameToMenuMapping: Record<string, string[]> = {
'halaman utama': ['dashboard', 'halaman utama'],
'pengaturan': ['master data'],
'halaman': ['master data'],
'dashboard': ['dashboard'],
};
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
// Try to match by pagename or menu name
const perm = permissionMap.get(item.name.toLowerCase()) ||
Array.from(permissionMap.values()).find(p =>
p.pagename?.toLowerCase().includes(item.name.toLowerCase()) ||
item.name.toLowerCase().includes(p.pagename?.toLowerCase())
);
let perm = permissionMap.get(item.name.toLowerCase());
// If no direct match, try fuzzy matching
if (!perm) {
perm = Array.from(permissionMap.values()).find(p => {
const pagenameLower = p.pagename?.toLowerCase() || '';
const menuNameLower = item.name.toLowerCase();
// Direct match
if (pagenameLower === menuNameLower) return true;
// Contains match
if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) {
return true;
}
// Check mapping
const mappedMenus = pagenameToMenuMapping[pagenameLower];
if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) {
return true;
}
return false;
});
}
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const hasChildren = filteredChildren.length > 0;
// If permission allows and has children, show item with filtered children
// If permission allows but no children, show item
// If no permission but has allowed children, show item with children
if (!allowThis && !hasChildren) return null;
return {
+239
View File
@@ -0,0 +1,239 @@
// middleware/permissions.ts
// Auto-save user permissions to localStorage when user is authenticated
import { defineNuxtRouteMiddleware } from '#app';
import { useLocalStorage } from '@vueuse/core';
import { useNavItemsStore } from '~/stores/navItems1';
interface NavItem {
id: number;
name: string;
path: string;
icon: string;
children?: NavItem[];
}
interface BackendPermission {
id: number;
create: boolean;
read: boolean;
update: boolean;
disable: boolean;
delete: boolean;
active: boolean;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface PermissionResponse {
message?: string;
data?: BackendPermission[];
meta?: {
count: number;
total: number;
};
error?: string;
}
// Save permissions to allHakAksesData in localStorage
const savePermissionsToHakAksesData = async (
backendPermissions: BackendPermission[],
role: string,
group: string
) => {
try {
// Get user data for additional info
const userData = await $fetch('/api/users/current').catch(() => null);
// Get existing hak akses data from localStorage
const allHakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
// Check if entry already exists for this role+group combination
const existingIndex = allHakAksesData.value.findIndex(
(item) => item.role === role && item.group === group
);
// Get navItemsStore to build menu template
const navItemsStore = useNavItemsStore();
// Build menu template from navItems
const buildMenuTemplate = (items: NavItem[]): any[] => {
const result: any[] = [];
const walk = (list: NavItem[]) => {
list.forEach((item) => {
result.push({
name: item.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
});
if (item.children?.length) {
walk(item.children);
}
});
};
walk(items);
return result;
};
const menuTemplate = buildMenuTemplate(navItemsStore.navItems);
// Map backend permissions to menu items
const mappedPermissions = menuTemplate.map((menu) => {
// Find matching permission from backend (by pagename or menu name)
const backendPerm = backendPermissions.find((perm) =>
perm.pagename?.toLowerCase() === menu.name.toLowerCase() ||
perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) ||
menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '')
);
if (backendPerm) {
return {
name: menu.name,
canAccess: backendPerm.active || backendPerm.read || false,
canView: backendPerm.read || false,
canAdd: backendPerm.create || false,
canEdit: backendPerm.update || false,
canDelete: backendPerm.delete || false,
};
}
return menu;
});
// Create hak akses data entry
const hakAksesEntry = {
id: existingIndex > -1 ? allHakAksesData.value[existingIndex].id :
(allHakAksesData.value.length > 0
? Math.max(...allHakAksesData.value.map(i => i.id || 0)) + 1
: 1),
userId: userData?.id || '',
namaLengkap: userData?.namaLengkap || '',
namaUser: userData?.namaUser || '',
tipeUser: userData?.tipeUser || '',
role: role,
group: group,
namaTipeUser: userData?.tipeUser || role,
hakAksesMenu: mappedPermissions,
// Store backend permissions for reference
backendPermissions: backendPermissions,
};
if (existingIndex > -1) {
// Update existing entry
allHakAksesData.value[existingIndex] = hakAksesEntry;
console.log('✅ [Permissions Middleware] Updated existing hak akses data for', role, '/', group);
} else {
// Add new entry
allHakAksesData.value.push(hakAksesEntry);
console.log('✅ [Permissions Middleware] Added new hak akses data for', role, '/', group);
}
console.log('💾 [Permissions Middleware] Permissions saved to allHakAksesData:', {
role,
group,
permissionsCount: backendPermissions.length,
menuCount: mappedPermissions.length,
});
} catch (error) {
console.error('❌ [Permissions Middleware] Error saving permissions to hak akses data:', error);
}
};
// Fetch permissions from backend API and save to localStorage
const fetchAndSavePermissions = async () => {
// Skip on server-side
if (process.server) {
return;
}
// Check if we've already processed permissions in this session
const sessionKey = 'permissions_synced';
if (sessionStorage.getItem(sessionKey)) {
console.log('⏭️ [Permissions Middleware] Permissions already synced in this session');
return;
}
try {
// Get current user data with roles and groups
const userData = await $fetch('/api/users/current').catch(() => null);
if (!userData) {
console.warn('⚠️ [Permissions Middleware] No user data found');
return;
}
const roles = [
...(userData.realmRoles || []),
...(userData.roles || []),
];
// Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const groups: string[] = [];
(userData.groups || []).forEach((g: string) => {
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groups.push(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groups.push(parts[0]);
}
});
if (roles.length === 0 || groups.length === 0) {
console.warn('⚠️ [Permissions Middleware] No roles or groups found for current user');
return;
}
// Use first role and first group
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
console.log('🔄 [Permissions Middleware] Fetching permissions for', primaryRole, '/', primaryGroup);
// Fetch permissions from API
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data) && response.data.length > 0) {
// Save permissions to localStorage
await savePermissionsToHakAksesData(response.data, primaryRole, primaryGroup);
// Mark as synced in this session
sessionStorage.setItem(sessionKey, 'true');
console.log('✅ [Permissions Middleware] Permissions synced successfully');
} else {
console.warn('⚠️ [Permissions Middleware] No permissions data received from API');
}
} catch (error) {
console.error('❌ [Permissions Middleware] Error fetching/saving permissions:', error);
}
};
export default defineNuxtRouteMiddleware(async (to) => {
// Only run on client-side
if (process.server) {
return;
}
// Skip for login page
if (to.path === '/LoginPage') {
return;
}
// Run async permission sync (non-blocking)
// This will only run once per session due to sessionStorage check
fetchAndSavePermissions().catch(err => {
console.error('❌ [Permissions Middleware] Failed to sync permissions:', err);
});
});
+3 -2
View File
@@ -49,7 +49,8 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.114:3001",
authUrl: process.env.AUTH_ORIGIN,
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
@@ -64,7 +65,7 @@ export default defineNuxtConfig({
"~/assets/scss/main.scss",
],
devServer: {
host: "http://10.10.150.114", // Changed from "10.10.123.139"
host: "http://10.10.150.175", // Changed from "10.10.123.139"
port: 3001
},
+463 -50
View File
@@ -70,16 +70,17 @@
</div>
<!-- Button dengan gradient -->
<v-btn
class="gradient-button"
block
size="x-large"
elevation="8"
@click="checkMockStatus"
>
<v-icon start>mdi-camera</v-icon>
Simulasi Scan QR
</v-btn>
<div class="d-flex justify-center">
<v-btn
class="gradient-button fixed-width-button"
size="large"
elevation="8"
@click="checkMockStatus"
>
<v-icon start>mdi-camera</v-icon>
Simulasi Scan QR
</v-btn>
</div>
<!-- Info tambahan -->
<div class="info-card mt-6">
@@ -95,6 +96,26 @@
Tips: Pastikan pencahayaan cukup untuk hasil scan optimal
</v-alert>
</div>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-6">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="openHistoryDialog"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
</v-row>
</div>
</div>
</v-window-item>
@@ -131,47 +152,37 @@
</template>
</v-text-field>
<v-btn
:color="secondaryColor"
class="text-white font-weight-bold text-none gradient-button-secondary"
block
size="x-large"
type="submit"
elevation="8"
>
<v-icon start>mdi-login</v-icon>
Check-in Sekarang
</v-btn>
<div class="d-flex justify-center">
<v-btn
:color="secondaryColor"
class="text-white font-weight-bold text-none gradient-button-secondary fixed-width-button"
size="large"
type="submit"
elevation="8"
>
<v-icon start>mdi-login</v-icon>
Check-in Sekarang
</v-btn>
</div>
</v-form>
<!-- Quick Access Buttons -->
<div class="quick-actions mt-6">
<p class="text-caption text-grey text-center mb-3">Akses Cepat</p>
<v-row dense>
<v-col cols="6">
<v-col cols="12">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
@click="openHistoryDialog"
>
<v-icon start size="18">mdi-history</v-icon>
Riwayat
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
variant="outlined"
:color="primaryColor"
block
size="small"
class="text-none"
>
<v-icon start size="18">mdi-help-circle</v-icon>
Bantuan
</v-btn>
</v-col>
</v-row>
</div>
</div>
@@ -214,17 +225,18 @@
density="comfortable"
></v-select>
<v-btn
:color="primaryColor"
class="gradient-button"
block
size="x-large"
type="submit"
elevation="8"
>
<v-icon start>mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
<div class="d-flex justify-center">
<v-btn
:color="primaryColor"
class="gradient-button fixed-width-button"
size="large"
type="submit"
elevation="8"
>
<v-icon start>mdi-qrcode-plus</v-icon>
Generate QR Code
</v-btn>
</div>
</v-form>
<!-- QR Code Display -->
@@ -493,6 +505,167 @@
</div>
</v-snackbar>
<!-- History Dialog -->
<v-dialog
v-model="historyDialog"
max-width="800"
persistent
transition="dialog-transition"
scrim="rgba(0, 0, 0, 0.5)"
class="blur-dialog"
>
<v-card class="rounded-xl dialog-card" elevation="24">
<div class="dialog-header text-center pa-6" style="background: linear-gradient(135deg, #1565C0 0%, #0D47A1 100%);">
<h2 class="text-h5 font-weight-bold text-white mb-2">
<v-icon color="white" class="mr-2">mdi-history</v-icon>
Riwayat Check-in
</h2>
<p class="text-body-2 text-white opacity-90">Daftar check-in yang telah dilakukan</p>
</div>
<v-card-text class="pa-6">
<!-- Filter dan Search -->
<div class="mb-4">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="historySearch"
label="Cari ID Pasien atau Nomor Antrean"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="historyStatusFilter"
label="Filter Status"
:items="historyStatusOptions"
variant="outlined"
density="comfortable"
hide-details
clearable
></v-select>
</v-col>
<v-col cols="12" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="clearHistory"
:disabled="checkInHistory.length === 0"
>
<v-icon start>mdi-delete</v-icon>
Hapus Semua
</v-btn>
</v-col>
</v-row>
</div>
<!-- History List -->
<div v-if="filteredHistory.length > 0" class="history-list">
<v-card
v-for="(item, index) in filteredHistory"
:key="index"
variant="outlined"
class="mb-3 history-item"
:class="getStatusClass(item.status)"
>
<v-card-text class="pa-4">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="d-flex align-center mb-2">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="mr-2"
>
<v-icon start size="16">{{ getStatusIcon(item.status) }}</v-icon>
{{ getStatusText(item.status) }}
</v-chip>
<v-chip
color="grey-lighten-1"
size="x-small"
variant="text"
>
{{ item.method }}
</v-chip>
</div>
<div class="mb-2">
<p class="text-body-1 font-weight-bold mb-1">
<v-icon size="18" class="mr-1" :color="primaryColor">mdi-account-circle</v-icon>
ID Pasien: {{ item.patientId }}
</p>
<p v-if="item.queueNumber" class="text-body-2 text-grey mb-1">
<v-icon size="16" class="mr-1">mdi-ticket</v-icon>
Nomor Antrean: {{ item.queueNumber }}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-clock-outline</v-icon>
{{ formatDateTime(item.checkInTime) }}
</v-chip>
<v-chip
v-if="item.checkInDate"
size="x-small"
variant="outlined"
color="grey-darken-1"
>
<v-icon start size="14">mdi-calendar</v-icon>
{{ formatDate(item.checkInDate) }}
</v-chip>
</div>
</div>
<div class="ml-4">
<v-btn
icon
size="small"
variant="text"
color="error"
@click="deleteHistoryItem(index)"
>
<v-icon>mdi-delete-outline</v-icon>
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-history</v-icon>
<p class="text-h6 text-grey mb-2">Belum ada riwayat check-in</p>
<p class="text-body-2 text-grey">Riwayat check-in akan muncul di sini setelah Anda melakukan check-in</p>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer></v-spacer>
<v-btn
color="primary"
class="text-white font-weight-bold text-none"
size="large"
variant="flat"
@click="historyDialog = false"
prepend-icon="mdi-close"
>
Tutup
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>
@@ -500,9 +673,11 @@
import { ref, computed, nextTick } from 'vue';
definePageMeta({
middleware:['auth']
middleware:['auth'],
layout: false,
})
// TypeScript declaration for QRCode
declare global {
interface Window {
@@ -527,6 +702,25 @@ const scannedData = ref<string | null>(null);
const manualInput = ref('');
const manualForm = ref(null);
// History Dialog
const historyDialog = ref(false);
const historySearch = ref('');
const historyStatusFilter = ref('');
const checkInHistory = ref<Array<{
patientId: string;
queueNumber?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
}>>([]);
const historyStatusOptions = [
{ title: 'Berhasil', value: 'success' },
{ title: 'Gagal', value: 'failed' },
{ title: 'Pending', value: 'pending' }
];
// Generate QR variables
const generatePatientId = ref('P12345');
const generateStatus = ref('ALLOWED');
@@ -585,7 +779,19 @@ const handleInfoAction = async () => {
const performCheckIn = async (data: string): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.random() < 0.8;
const success = Math.random() < 0.8;
// Simpan ke history (baik berhasil maupun gagal)
const [patientId, status] = data.split('|');
saveToHistory({
patientId: patientId || 'Unknown',
status: success ? (status || 'ALLOWED') : 'failed',
checkInTime: new Date().toISOString(),
checkInDate: new Date().toISOString(),
method: scannedData.value ? 'QR Scan' : 'Manual'
});
return success;
};
const showSnackbar = (title: string, message: string, color: string, icon: string) => {
@@ -596,9 +802,23 @@ const showSnackbar = (title: string, message: string, color: string, icon: strin
snackbar.value.show = true;
};
const checkInManual = () => {
if (manualForm.value) {
showSnackbar('Info', 'Check-in Manual sedang diproses.', 'info', 'mdi-information');
const checkInManual = async () => {
if (!manualInput.value) {
showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert');
return;
}
// Simulasi check-in manual
const success = await performCheckIn(`${manualInput.value}|ALLOWED`);
if (success) {
showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle');
manualInput.value = '';
if (manualForm.value) {
(manualForm.value as any).reset();
}
} else {
showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle');
}
};
@@ -686,6 +906,143 @@ const shareQR = async () => {
});
}
};
// History Functions
const HISTORY_STORAGE_KEY = 'checkin_history';
const loadHistory = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
if (stored) {
try {
checkInHistory.value = JSON.parse(stored);
} catch (e) {
console.error('Error loading history:', e);
checkInHistory.value = [];
}
}
}
};
const saveToHistory = (item: {
patientId: string;
queueNumber?: string;
status: string;
checkInTime: string;
checkInDate: string;
method: string;
}) => {
const historyItem = {
...item,
queueNumber: item.queueNumber || `ANT-${Date.now()}`,
};
checkInHistory.value.unshift(historyItem);
// Simpan maksimal 100 item
if (checkInHistory.value.length > 100) {
checkInHistory.value = checkInHistory.value.slice(0, 100);
}
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value));
}
};
const deleteHistoryItem = (index: number) => {
checkInHistory.value.splice(index, 1);
if (typeof window !== 'undefined') {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value));
}
showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check');
};
const clearHistory = () => {
checkInHistory.value = [];
if (typeof window !== 'undefined') {
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check');
};
const openHistoryDialog = () => {
loadHistory();
historyDialog.value = true;
};
const filteredHistory = computed(() => {
let filtered = [...checkInHistory.value];
// Filter by search
if (historySearch.value) {
const search = historySearch.value.toLowerCase();
filtered = filtered.filter(item =>
item.patientId.toLowerCase().includes(search) ||
(item.queueNumber && item.queueNumber.toLowerCase().includes(search))
);
}
// Filter by status
if (historyStatusFilter.value) {
filtered = filtered.filter(item => {
if (historyStatusFilter.value === 'success') {
return item.status === 'ALLOWED' || item.status === 'success';
} else if (historyStatusFilter.value === 'failed') {
return item.status === 'NOT_ALLOWED' || item.status === 'failed';
}
return true;
});
}
return filtered;
});
const getStatusColor = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'error';
return 'warning';
};
const getStatusIcon = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle';
return 'mdi-clock-alert';
};
const getStatusText = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'Berhasil';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal';
return 'Pending';
};
const getStatusClass = (status: string) => {
if (status === 'ALLOWED' || status === 'success') return 'history-success';
if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed';
return 'history-pending';
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
};
// Load history on mount
if (typeof window !== 'undefined') {
loadHistory();
}
</script>
<style scoped>
@@ -883,6 +1240,12 @@ const shareQR = async () => {
box-shadow: 0 8px 24px rgba(251, 140, 0, 0.4) !important;
}
.fixed-width-button {
width: 280px !important;
min-width: 280px !important;
max-width: 280px !important;
}
.custom-input :deep(.v-field) {
border-radius: 12px;
font-size: 16px;
@@ -1037,6 +1400,56 @@ const shareQR = async () => {
min-width: 300px;
}
/* History Dialog Styles */
.history-list {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
.history-item {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.history-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.history-success {
border-left-color: #4caf50;
background: rgba(76, 175, 80, 0.05);
}
.history-failed {
border-left-color: #f44336;
background: rgba(244, 67, 54, 0.05);
}
.history-pending {
border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.05);
}
/* Mobile Optimization */
@media (max-width: 600px) {
.v-container.fill-height {
+4 -4
View File
@@ -138,7 +138,7 @@ const mockPatientData = [
activeTickets: [
// 1. TIKET JIWA
{
title: 'Klinik JIWA (Antrean: 1)',
title: 'JIWA (Antrean: 1)',
color: 'orange-darken-1',
currentStepLabel: 'Klinik Jiwa',
steps: [
@@ -152,7 +152,7 @@ const mockPatientData = [
},
// 2. TIKET RADIOLOGI
{
title: 'Penunjang RADIOLOGI (Antrean: 5)',
title: 'RADIOLOGI (Antrean: 5)',
color: 'blue-darken-2',
currentStepLabel: 'Loket 5/6/7',
steps: [
@@ -165,7 +165,7 @@ const mockPatientData = [
},
// 3. TIKET GIZI
{
title: 'Klinik GIZI (Antrean: 12)',
title: 'GIZI (Antrean: 12)',
color: 'purple-darken-1',
currentStepLabel: 'Konsultasi Gizi',
steps: [
@@ -188,7 +188,7 @@ const mockPatientData = [
},
// 5. TIKET LABORATORIUM
{
title: 'Penunjang LABORATORIUM (Antrean: 7)',
title: 'LABORATORIUM (Antrean: 7)',
color: 'red-darken-2',
currentStepLabel: 'Pemeriksaan Sampel',
steps: [
-105
View File
@@ -167,35 +167,6 @@
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">NOMOR TELEPON</label>
<v-text-field
v-model="profileData.phone"
placeholder="Masukkan nomor telepon"
prepend-inner-icon="mdi-phone"
variant="outlined"
density="comfortable"
color="blue-darken-2"
:readonly="!isEditing"
hide-details="auto"
class="mb-4"
></v-text-field>
</v-col>
<v-col cols="12">
<label class="text-caption text-grey-darken-1 font-weight-bold mb-1 d-block">BIO</label>
<v-textarea
v-model="profileData.bio"
placeholder="Ceritakan tentang diri Anda"
prepend-inner-icon="mdi-text"
variant="outlined"
color="blue-darken-2"
rows="3"
:readonly="!isEditing"
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-form>
</v-card-text>
@@ -245,82 +216,6 @@
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item
class="rounded-lg mb-2 px-4"
prepend-icon="mdi-two-factor-authentication"
>
<v-list-item-title class="font-weight-medium">Autentikasi Dua Faktor</v-list-item-title>
<v-list-item-subtitle class="text-caption">Tingkatkan keamanan akun</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="twoFactorEnabled"
color="blue-darken-2"
hide-details
inset
@change="toggleTwoFactor"
></v-switch>
</template>
</v-list-item>
<v-list-item
class="rounded-lg px-4"
prepend-icon="mdi-devices"
@click="openDevicesDialog"
>
<v-list-item-title class="font-weight-medium">Perangkat Aktif</v-list-item-title>
<v-list-item-subtitle class="text-caption">Kelola perangkat yang terhubung</v-list-item-subtitle>
<template v-slot:append>
<v-chip size="small" color="success" variant="flat">{{ activeSessions.length }} Perangkat</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<!-- Preferences Section -->
<v-card class="rounded-xl elevation-4">
<v-card-title class="d-flex align-center pa-6 pb-4">
<v-icon color="blue-darken-2" class="mr-2">mdi-tune</v-icon>
<span class="font-weight-bold">Preferensi</span>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-6">
<v-list class="transparent">
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-bell">
<v-list-item-title class="font-weight-medium">Notifikasi Email</v-list-item-title>
<v-list-item-subtitle class="text-caption">Terima update via email</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="emailNotifications"
color="orange-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg mb-2 px-4" prepend-icon="mdi-theme-light-dark">
<v-list-item-title class="font-weight-medium">Tema Gelap</v-list-item-title>
<v-list-item-subtitle class="text-caption">Aktifkan mode gelap</v-list-item-subtitle>
<template v-slot:append>
<v-switch
v-model="darkMode"
color="blue-darken-2"
hide-details
inset
></v-switch>
</template>
</v-list-item>
<v-list-item class="rounded-lg px-4" prepend-icon="mdi-web">
<v-list-item-title class="font-weight-medium">Bahasa</v-list-item-title>
<v-list-item-subtitle class="text-caption">Indonesia</v-list-item-subtitle>
<template v-slot:append>
<v-icon color="grey">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
+164 -23
View File
@@ -462,9 +462,59 @@
</div>
</div>
<!-- Filter bar -->
<v-row class="mb-4" dense>
<v-col cols="12" sm="3">
<v-select
v-model="filterUserId"
:items="filterUserIdOptions"
item-title="label"
item-value="value"
label="Filter User ID"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="filterTipeUser"
:items="availableTipeUsers"
label="Filter Tipe User"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="filterRole"
:items="availableRoles"
label="Filter Role"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="filterGroup"
:items="availableGroups"
label="Filter Group"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
</v-row>
<v-data-table
:headers="headers"
:items="allHakAksesData"
:items="filteredHakAksesData"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
@@ -492,10 +542,17 @@
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot:item.id="{ item }">
<div class="text-center">{{ item.id }}</div>
</template>
<!-- Tampilkan User ID virtual yang dikelompokkan per tipe user -->
<template v-slot:item.userId="{ item }">
<div class="text-center">
{{ formatDisplayUserId(item) }}
</div>
</template>
<template v-slot:bottom>
<v-row class="ma-2 pa-2">
@@ -642,9 +699,10 @@ const navItemsStore = useNavItemsStore();
const draggableMenus = ref<NavItem[]>([]);
// Data table headers
// Kolom "No" dan "User ID" di-center agar sejajar dengan isi sel yang juga center
const headers = ref([
{ title: 'No', key: 'id' as const },
{ title: 'User ID', key: 'userId' as const, sortable: true },
{ title: 'No', key: 'id' as const, align: 'center' as const },
{ title: 'User ID', key: 'userId' as const, sortable: true, align: 'center' as const },
{ title: 'Nama Lengkap', key: 'namaLengkap' as const, sortable: true },
{ title: 'Nama User', key: 'namaUser' as const, sortable: true },
{ title: 'Tipe User', key: 'tipeUser' as const, sortable: true },
@@ -740,6 +798,85 @@ const showingEntriesText = computed(() => {
return `Showing ${start} to ${end} of ${total} entries`;
});
// Filter state
const filterUserId = ref<number | null>(null);
const filterTipeUser = ref<string | null>(null);
const filterRole = ref<string | null>(null);
const filterGroup = ref<string | null>(null);
// Opsi dropdown User ID (pakai ID baris + label User ID virtual)
const filterUserIdOptions = computed(() =>
Array.from(
new Map(
allHakAksesData.value.map((item) => [
item.id,
{
value: item.id,
label: formatDisplayUserId(item),
},
]),
).values(),
),
);
// Data tabel setelah difilter oleh dropdown
const filteredHakAksesData = computed(() =>
allHakAksesData.value.filter((item) => {
const matchUserId = !filterUserId.value || item.id === filterUserId.value;
const matchTipeUser = !filterTipeUser.value || (item.tipeUser || '') === filterTipeUser.value;
const matchRole = !filterRole.value || item.role === filterRole.value;
const matchGroup = !filterGroup.value || item.group === filterGroup.value;
return matchUserId && matchTipeUser && matchRole && matchGroup;
}),
);
// Generate display User ID grouped by tipeUser
const getTipeUserCode = (tipeUser?: string): string => {
if (!tipeUser) return 'USR';
const normalized = tipeUser.toLowerCase().trim();
if (normalized.includes('super') && normalized.includes('admin')) return 'SA';
if (normalized === 'admin') return 'ADM';
if (normalized.includes('loket')) return 'LOK';
if (normalized.includes('klinik')) return 'KLN';
if (normalized.includes('barcode')) return 'BAR';
if (normalized.includes('inova')) return 'INV';
if (normalized.includes('ranap')) return 'RNP';
if (normalized.includes('report')) return 'RPT';
if (normalized.includes('farmasi')) return 'FRM';
if (normalized.includes('manager')) return 'MGR';
// Default: ambil inisial dari tiap kata, maksimal 3 huruf
const initials = normalized
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0]?.toUpperCase() || '')
.join('')
.slice(0, 3);
return initials || 'USR';
};
const formatDisplayUserId = (item: HakAksesData): string => {
const tipe = item.tipeUser || 'Unknown';
const prefix = getTipeUserCode(tipe);
// Kelompokkan berdasarkan tipeUser, urut berdasarkan userId untuk konsistensi
const sameTypeItems = allHakAksesData.value
.filter((i) => (i.tipeUser || 'Unknown') === tipe)
.slice()
.sort((a, b) => (a.userId || '').localeCompare(b.userId || ''));
const index = sameTypeItems.findIndex(
(i) => i.userId === item.userId && (i.tipeUser || 'Unknown') === tipe
);
const number = index >= 0 ? index + 1 : sameTypeItems.length + 1;
return `${prefix}-${String(number).padStart(4, '0')}`;
};
const formTitle = computed(() => {
if (viewMode.value === 'editName') return 'Edit Nama Tipe User';
if (viewMode.value === 'editAccess') return 'Edit Hak Akses';
@@ -828,9 +965,30 @@ const cancelForm = () => {
resetForm();
};
const updateItemAccess = (updatedItem: HakAksesData) => {
const updateItemAccess = (updatedItem: HakAksesData & { backendPermissions?: BackendPermissionItem[] }) => {
if (editedIndex.value > -1) {
Object.assign(allHakAksesData.value[editedIndex.value], updatedItem);
const itemToUpdate = allHakAksesData.value[editedIndex.value];
// If backend permissions are provided, we can store them or map them to menu structure
if (updatedItem.backendPermissions && updatedItem.backendPermissions.length > 0) {
// Store backend permissions for reference
(itemToUpdate as any).backendPermissions = updatedItem.backendPermissions;
// Also update the menu structure if needed
if (updatedItem.hakAksesMenu) {
itemToUpdate.hakAksesMenu = updatedItem.hakAksesMenu;
}
} else {
// Use existing menu structure update
Object.assign(itemToUpdate, updatedItem);
}
snackbar.value = {
show: true,
message: 'Hak akses berhasil diperbarui!',
color: 'success',
timeout: 3000,
};
}
cancelForm();
};
@@ -1112,23 +1270,6 @@ const saveItem = async () => {
return;
}
// Check for duplicate role+group combination
const existing = allHakAksesData.value.find(
item => item.role === editedItem.value.role &&
item.group === editedItem.value.group &&
item.id !== editedItem.value.id
);
if (existing) {
snackbar.value = {
show: true,
message: 'Kombinasi Role dan Group sudah ada!',
color: 'warning',
timeout: 3000,
};
return;
}
// If hakAksesMenu is empty or not fetched yet, fetch from backend
const needsFetch = !editedItem.value.hakAksesMenu ||
editedItem.value.hakAksesMenu.length === 0 ||
+298 -52
View File
@@ -186,18 +186,29 @@
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login Management</span>
</v-card-title>
<v-btn
color="white"
prepend-icon="mdi-refresh"
rounded
class="text-capitalize"
@click="refreshAllUsers"
:disabled="isPending || isSaving"
:loading="isSaving"
variant="outlined"
>
Refresh Data
</v-btn>
<div class="d-flex align-center gap-2">
<v-chip
v-if="autoRefreshInterval"
color="success"
size="small"
variant="flat"
prepend-icon="mdi-sync"
>
Auto-refresh aktif
</v-chip>
<v-btn
color="white"
prepend-icon="mdi-refresh"
rounded
class="text-capitalize"
@click="refreshAllUsers"
:disabled="isPending || isSaving"
:loading="isSaving"
variant="outlined"
>
Sync dari Keycloak
</v-btn>
</div>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
@@ -230,10 +241,64 @@
></v-text-field>
</div>
</div>
<!-- Filter row: tipe user, account roles, resource roles, groups -->
<v-row class="mb-4" dense>
<v-col cols="12" md="3">
<v-select
v-model="selectedTipeUser"
:items="availableTipeUsers"
label="Filter Tipe User"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="selectedAccountRoles"
:items="availableAccountRoles"
label="Filter Account Roles"
multiple
chips
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="selectedResourceRoles"
:items="availableResourceRoles"
label="Filter Resource Roles"
multiple
chips
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="selectedGroups"
:items="availableGroups"
label="Filter Groups"
multiple
chips
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
</v-row>
<v-data-table
:headers="headers"
:items="allUserData"
:items="filteredUsers"
:search="search"
:items-per-page="itemsPerPage"
v-model:page="page"
@@ -297,6 +362,13 @@
<span class="text-body-2">
{{ formatLastLogin(item.lastLogin) }}
</span>
<v-tooltip
v-if="item.lastLogin && item.updatedAt"
activator="parent"
location="top"
>
Last updated: {{ new Date(item.updatedAt * 1000).toLocaleString('id-ID') }}
</v-tooltip>
</template>
<template v-slot:item.actions="{ item }">
@@ -360,7 +432,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
// ----------------------------------------------------------------------
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
@@ -387,6 +459,7 @@ interface UserManagementItem {
resourceRoles: string[]; // Roles from other resources (format: "resource:role")
groups: string[];
password?: string;
updatedAt?: number; // Unix timestamp (seconds) when user record was last updated
}
// Data structure for initial API response
@@ -427,11 +500,13 @@ const fetchCurrentUser = async () => {
// 3. Auto-sync current user when page loads (first time login check)
const syncCurrentUser = async () => {
try {
await $fetch('/api/users/sync', { method: 'POST' });
console.log('✅ User synced successfully');
const result = await $fetch('/api/users/sync', { method: 'POST' });
console.log('✅ User synced successfully:', result);
return result;
} catch (error: any) {
console.error('Failed to sync user:', error);
console.error('Failed to sync user:', error);
// Don't show error to user, just log it
throw error; // Re-throw so caller knows it failed
}
};
@@ -498,43 +573,126 @@ watch(initialData, (newData) => {
}
}, { deep: true });
// Auto-sync user on mount (client-side only)
onMounted(async () => {
// Fetch current user data first
await fetchCurrentUser();
// Sync current user when page loads
await syncCurrentUser();
// Refresh user list after sync
await refresh();
});
// Auto-refresh interval and focus handler
let autoRefreshInterval: NodeJS.Timeout | null = null;
let focusHandler: (() => void) | null = null;
// Function to refresh all users data (re-sync from tokens)
// Function to refresh all users data (sync all users from Keycloak)
const refreshAllUsers = async () => {
try {
isSaving.value = true;
// Call sync endpoint to update current user
await syncCurrentUser();
// Call sync-all endpoint to sync all users from Keycloak
const result = await $fetch('/api/users/sync-all', { method: 'POST' });
// Refresh the list
await refresh();
snackbar.value = {
show: true,
message: 'Data user berhasil di-refresh dari Keycloak!',
message: `Data user berhasil di-refresh! ${result.stats?.created || 0} baru, ${result.stats?.updated || 0} diperbarui`,
color: 'success',
timeout: 3000
timeout: 4000
};
} catch (error: any) {
console.error('Error refreshing users:', error);
snackbar.value = {
show: true,
message: 'Gagal refresh data user',
message: error.data?.message || 'Gagal refresh data user',
color: 'error',
timeout: 3000
timeout: 4000
};
} finally {
isSaving.value = false;
}
};
// Auto-refresh function
const startAutoRefresh = () => {
// Clear existing interval if any
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
// Auto-refresh every 30 seconds
autoRefreshInterval = setInterval(async () => {
if (!isSaving.value && !isPending.value) {
console.log('🔄 Auto-refreshing user data...');
await refresh();
}
}, 30000); // 30 seconds
};
// Stop auto-refresh
const stopAutoRefresh = () => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
};
// Auto-sync user on mount (client-side only)
onMounted(async () => {
// Fetch current user data first
await fetchCurrentUser();
// Sync current user when page loads - this ensures the logged-in user is in the database
try {
const syncResult = await syncCurrentUser();
console.log('✅ Current user synced:', syncResult);
// If current user was just created, refresh the list immediately
if (syncResult?.action === 'created') {
console.log('🆕 New user detected, refreshing list...');
await refresh();
}
} catch (error) {
console.error('⚠️ Failed to sync current user, but continuing...', error);
}
// Check if current user exists in the list
const currentUserId = currentUserData.value?.id;
if (currentUserId) {
const userExists = allUserData.value.some(u => u.id === currentUserId);
if (!userExists) {
console.log('⚠️ Current user not found in list, refreshing...');
await refresh();
// If still not found after refresh, try sync-all
const stillNotFound = !allUserData.value.some(u => u.id === currentUserId);
if (stillNotFound) {
console.log('⚠️ Current user still not found, attempting sync-all...');
try {
await refreshAllUsers();
} catch (syncAllError) {
console.error('❌ Sync-all failed:', syncAllError);
}
}
}
}
// Refresh user list after sync to show the current user
await refresh();
// Start auto-refresh
startAutoRefresh();
// Refresh on window focus
focusHandler = () => {
if (!isSaving.value && !isPending.value) {
console.log('🔄 Window focused, refreshing user data...');
refresh().catch(err => console.error('Error refreshing on focus:', err));
}
};
window.addEventListener('focus', focusHandler);
});
// Cleanup on unmount
onUnmounted(() => {
stopAutoRefresh();
if (focusHandler) {
window.removeEventListener('focus', focusHandler);
focusHandler = null;
}
});
// --- LOCAL STATE ---
@@ -565,6 +723,12 @@ const search = ref('');
const page = ref(1);
const itemToDelete = ref<UserManagementItem | null>(null);
// Filter state
const selectedTipeUser = ref<string | null>(null);
const selectedAccountRoles = ref<string[]>([]);
const selectedResourceRoles = ref<string[]>([]);
const selectedGroups = ref<string[]>([]);
// State for dialog and form
const showForm = ref(false);
const readOnly = ref(false);
@@ -596,33 +760,115 @@ const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
// --- COMPUTED PROPERTIES ---
// Options for filters, derived from current user data
const availableTipeUsers = computed<string[]>(() => {
const set = new Set<string>();
allUserData.value.forEach((u) => {
if (u.tipeUser) set.add(u.tipeUser);
});
return Array.from(set).sort();
});
const availableAccountRoles = computed<string[]>(() => {
const set = new Set<string>();
allUserData.value.forEach((u) => {
(u.accountRoles || []).forEach((r) => set.add(r));
});
return Array.from(set).sort();
});
const availableResourceRoles = computed<string[]>(() => {
const set = new Set<string>();
allUserData.value.forEach((u) => {
(u.resourceRoles || []).forEach((r) => set.add(r));
});
return Array.from(set).sort();
});
// Filtered users list used by table & pagination
const filteredUsers = computed<UserManagementItem[]>(() => {
return allUserData.value.filter((user) => {
// Filter by tipe user (single select)
if (selectedTipeUser.value && user.tipeUser !== selectedTipeUser.value) {
return false;
}
// Filter by account roles (multi-select, match any)
if (selectedAccountRoles.value.length > 0) {
const userAccountRoles = user.accountRoles || [];
const hasMatch = selectedAccountRoles.value.some((role) =>
userAccountRoles.includes(role)
);
if (!hasMatch) return false;
}
// Filter by resource roles (multi-select, match any)
if (selectedResourceRoles.value.length > 0) {
const userResourceRoles = user.resourceRoles || [];
const hasMatch = selectedResourceRoles.value.some((role) =>
userResourceRoles.includes(role)
);
if (!hasMatch) return false;
}
// Filter by groups (multi-select, match any)
if (selectedGroups.value.length > 0) {
const userGroups = user.groups || [];
const hasMatch = selectedGroups.value.some((g) =>
userGroups.includes(g)
);
if (!hasMatch) return false;
}
return true;
});
});
// Format last access time (data from Keycloak)
const formatLastLogin = (timestamp: number | null): string => {
if (!timestamp) return 'Belum pernah login';
const formatLastLogin = (timestamp: number | null | undefined): string => {
// Check if timestamp is valid (not null, not undefined, not 0)
if (!timestamp || timestamp === 0 || isNaN(timestamp)) {
return 'Belum pernah login';
}
// Convert Unix timestamp (seconds) to Date object
const date = new Date(timestamp * 1000);
// Format like: "10 Desember 2025 pukul 13.00"
return date.toLocaleString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(',', ' pukul');
try {
// Handle both seconds and milliseconds timestamps
let date: Date;
if (timestamp > 10000000000) {
// Timestamp is in milliseconds
date = new Date(timestamp);
} else {
// Timestamp is in seconds
date = new Date(timestamp * 1000);
}
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Belum pernah login';
}
// Format like: "10 Desember 2025 pukul 13.00"
return date.toLocaleString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(',', ' pukul');
} catch (error) {
console.error('Error formatting last login:', error, timestamp);
return 'Belum pernah login';
}
};
const pageCount = computed(() => {
// Using filtered/searched data length for accurate count if search was handled client-side,
// but since we are using v-data-table's built-in search, we use the full length here
// for a simple calculation of total pages based on all data.
return Math.ceil(allUserData.value.length / itemsPerPage.value);
// Use filtered data length so pagination matches applied filters
return Math.ceil(filteredUsers.value.length / itemsPerPage.value) || 1;
});
const showingEntriesText = computed(() => {
const total = allUserData.value.length;
const total = filteredUsers.value.length;
if (total === 0) return 'Showing 0 to 0 of 0 entries';
const start = (page.value - 1) * itemsPerPage.value + 1;
const end = Math.min(page.value * itemsPerPage.value, total);
+54 -7
View File
@@ -51,9 +51,26 @@ export default defineEventHandler(async (event) => {
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
// Validate Keycloak configuration
if (!config.keycloakIssuer) {
console.error('❌ KEYCLOAK_ISSUER is not configured');
const errorMsg = encodeURIComponent('Keycloak server is not configured. Please contact administrator.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
if (!config.keycloakClientId || !config.keycloakClientSecret) {
console.error('❌ Keycloak client credentials are not configured');
const errorMsg = encodeURIComponent('Keycloak client credentials are missing. Please contact administrator.');
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
}
const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`;
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`;
console.log('🔗 Token URL:', tokenUrl);
console.log('🔗 Redirect URI:', redirectUri);
console.log('🔑 Client ID:', config.keycloakClientId ? '***configured***' : 'MISSING');
const tokenPayload = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.keycloakClientId,
@@ -62,13 +79,43 @@ export default defineEventHandler(async (event) => {
redirect_uri: redirectUri,
});
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: tokenPayload,
});
let tokenResponse;
try {
// Create abort controller for timeout (compatible with all Node.js versions)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: tokenPayload,
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (fetchError: any) {
console.error('❌ Fetch error details:');
console.error(' - Error type:', fetchError.name);
console.error(' - Error message:', fetchError.message);
console.error(' - Token URL attempted:', tokenUrl);
// Provide more specific error messages
let errorMsg = 'Failed to connect to authentication server.';
if (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) {
errorMsg = 'Authentication server timeout. Please try again.';
} else if (fetchError.message.includes('ENOTFOUND') || fetchError.message.includes('getaddrinfo')) {
errorMsg = 'Cannot reach authentication server. Please check network connection.';
} else if (fetchError.message.includes('ECONNREFUSED')) {
errorMsg = 'Authentication server refused connection. Server may be down.';
} else if (fetchError.message.includes('certificate') || fetchError.message.includes('SSL')) {
errorMsg = 'SSL certificate error. Please contact administrator.';
}
const encodedError = encodeURIComponent(errorMsg);
return sendRedirect(event, `/LoginPage?error=${encodedError}`);
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
+171 -6
View File
@@ -1,5 +1,141 @@
// server/api/permission.get.ts
// Proxy endpoint to fetch permissions from backend API
// Proxy endpoint to fetch permissions from backend API with placeholder fallback
// Placeholder data for testing (matching the example API response)
const PLACEHOLDER_PERMISSIONS: Record<string, any> = {
'superadmin_STIM': {
message: "Data permission berhasil diambil",
data: [
{
id: 1,
create: false,
read: true,
update: false,
disable: false,
delete: false,
active: true,
pagename: "Halaman Utama",
pagesID: 1,
level: 1,
sort: 1
},
{
id: 6,
create: false,
read: true,
update: false,
disable: false,
delete: false,
active: true,
pagename: "Halaman Utama",
pagesID: 1,
level: 1,
sort: 1
},
{
id: 2,
create: false,
read: true,
update: false,
disable: false,
delete: false,
active: true,
pagename: "Pengaturan",
pagesID: 2,
level: 1,
sort: 2
},
{
id: 3,
create: false,
read: true,
update: false,
disable: true,
delete: false,
active: true,
pagename: "Halaman",
pagesID: 3,
level: 2,
sort: 3,
parent: 2
},
{
id: 7,
create: false,
read: true,
update: false,
disable: true,
delete: false,
active: true,
pagename: "Dashboard",
pagesID: 15,
level: 1,
sort: 2
}
],
meta: {
count: 5,
total: 5
}
}
};
// Mapping untuk role dan group yang berbeda
const roleGroupMapping: Record<string, { role: string; group: string }> = {
// Mapping untuk role default-roles-sandbox dengan group Instalasi STIM
'default-roles-sandbox_instalasi stim': { role: 'superadmin', group: 'STIM' },
'default-roles-sandbox_stim': { role: 'superadmin', group: 'STIM' },
// Tambahkan mapping lain jika diperlukan
};
// Normalize group name (remove "Instalasi" prefix if exists)
const normalizeGroup = (group: string): string => {
const normalized = group.trim();
// Jika group mengandung "Instalasi STIM", ambil hanya "STIM"
if (normalized.toLowerCase().includes('instalasi')) {
const parts = normalized.split(/\s+/);
const stimIndex = parts.findIndex(p => p.toLowerCase() === 'stim');
if (stimIndex !== -1) {
return 'STIM';
}
}
// Jika group adalah "Instalasi STIM", return "STIM"
if (normalized.toLowerCase() === 'instalasi stim') {
return 'STIM';
}
return normalized.toUpperCase();
};
// Normalize role name
const normalizeRole = (role: string): string => {
const normalized = role.toLowerCase().trim();
// Mapping khusus untuk role default
if (normalized === 'default-roles-sandbox') {
return 'superadmin';
}
return normalized;
};
// Get placeholder data for a role+group combination
const getPlaceholderData = (role: string, group: string): any | null => {
const normalizedRole = normalizeRole(role);
const normalizedGroup = normalizeGroup(group);
const key = `${normalizedRole}_${normalizedGroup}`;
// Check direct match
let data = PLACEHOLDER_PERMISSIONS[key];
if (data) return data;
// Check mapping
const mappingKey = `${role.toLowerCase()}_${group.toLowerCase()}`;
const mapping = roleGroupMapping[mappingKey];
if (mapping) {
const mappedKey = `${mapping.role.toLowerCase()}_${mapping.group.toUpperCase()}`;
return PLACEHOLDER_PERMISSIONS[mappedKey] || null;
}
return null;
};
export default defineEventHandler(async (event) => {
console.log("🔐 Permission endpoint called");
@@ -8,6 +144,12 @@ export default defineEventHandler(async (event) => {
const roles = query.roles as string | string[];
const groups = query.groups as string | string[];
// Check if placeholder mode is enabled via query parameter or environment variable
// Default to true for testing/development (use placeholder if backend fails)
const forcePlaceholder = query.usePlaceholder === 'true' ||
process.env.USE_PLACEHOLDER_PERMISSIONS === 'true';
const disablePlaceholder = query.usePlaceholder === 'false';
if (!roles && !groups) {
throw createError({
statusCode: 400,
@@ -20,16 +162,32 @@ export default defineEventHandler(async (event) => {
const groupsArray = Array.isArray(groups) ? groups : groups ? [groups] : [];
// Extract primary role and group (use first one or combine)
const primaryRole = rolesArray[0] || '';
const primaryGroup = groupsArray[0] || '';
let primaryRole = rolesArray[0] || '';
let primaryGroup = groupsArray[0] || '';
// Normalize role and group
primaryRole = normalizeRole(primaryRole);
primaryGroup = normalizeGroup(primaryGroup);
console.log(`📋 Normalized params - roles: ${primaryRole}, groups: ${primaryGroup}`);
// Build query parameters
// Check for placeholder data first if placeholder mode is forced
if (forcePlaceholder && !disablePlaceholder) {
const placeholderData = getPlaceholderData(primaryRole, primaryGroup);
if (placeholderData) {
console.log(`📦 Using placeholder data (forced) for role: ${primaryRole}, group: ${primaryGroup}`);
return placeholderData;
}
console.log(`⚠️ No placeholder data found for role: ${primaryRole}, group: ${primaryGroup}`);
}
// Build query parameters (use normalized values for API)
const params = new URLSearchParams();
if (primaryRole) params.append('roles', primaryRole);
if (primaryGroup) params.append('groups', primaryGroup);
// Backend API URL - adjust this to match your backend
const backendUrl = `http://10.10.150.131:8080/api/v1/permission?${params.toString()}`;
const backendUrl = `http://10.10.150.131:8089/api/v1/permission?${params.toString()}`;
try {
console.log(`📡 Fetching permissions from: ${backendUrl}`);
@@ -67,7 +225,14 @@ export default defineEventHandler(async (event) => {
data: error.data,
});
// Return empty permissions structure if API fails
// Fallback to placeholder data if available
const placeholderData = getPlaceholderData(primaryRole, primaryGroup);
if (placeholderData) {
console.log(`📦 Falling back to placeholder data for role: ${primaryRole}, group: ${primaryGroup}`);
return placeholderData;
}
// Return empty permissions structure if API fails and no placeholder available
return {
message: error.message || "Failed to fetch permissions",
data: [],
+133
View File
@@ -35,6 +35,31 @@ export default defineEventHandler(async (event) => {
const db = new Database(dbPath);
// Ensure schema is up to date
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const columnNames = tableInfo.map(col => col.name);
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
} catch (e: any) {
console.warn('Migration note:', e.message);
}
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
@@ -46,6 +71,114 @@ export default defineEventHandler(async (event) => {
});
}
// Handle password update to Keycloak if password is provided
if (body.password && body.password.trim() !== '') {
try {
const config = useRuntimeConfig();
// Get access token from current user session
let accessToken: string | null = null;
try {
const sessionCookie = getCookie(event, "user_session");
if (sessionCookie) {
const session = JSON.parse(sessionCookie);
const isExpired = Date.now() > session.expiresAt;
if (!isExpired && session.accessToken) {
accessToken = session.accessToken;
}
}
} catch (e) {
console.warn("⚠️ No valid session found for password update");
}
if (!accessToken) {
db.close();
throw createError({
statusCode: 401,
statusMessage: "Authentication required to update password",
});
}
// Extract realm from issuer
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const keycloakBaseUrl = config.keycloakIssuer.replace('/realms/' + realm, '');
const passwordUpdateUrl = `${keycloakBaseUrl}/admin/realms/${realm}/users/${userId}/reset-password`;
console.log(`🔐 Updating password for user ${userId} in Keycloak`);
console.log(`🔗 Password update URL: ${passwordUpdateUrl}`);
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
// Update password in Keycloak
let passwordResponse;
try {
passwordResponse = await fetch(passwordUpdateUrl, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'password',
value: body.password,
temporary: false, // Set to true if you want to force password change on next login
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (fetchError: any) {
clearTimeout(timeoutId);
console.error('❌ Fetch error during password update:');
console.error(' - Error type:', fetchError.name);
console.error(' - Error message:', fetchError.message);
console.error(' - URL attempted:', passwordUpdateUrl);
db.close();
let errorMsg = 'Failed to connect to Keycloak server to update password.';
if (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) {
errorMsg = 'Keycloak server timeout. Please try again.';
} else if (fetchError.message.includes('ENOTFOUND') || fetchError.message.includes('getaddrinfo')) {
errorMsg = 'Cannot reach Keycloak server. Please check network connection.';
} else if (fetchError.message.includes('ECONNREFUSED')) {
errorMsg = 'Keycloak server refused connection. Server may be down.';
} else if (fetchError.message.includes('certificate') || fetchError.message.includes('SSL')) {
errorMsg = 'SSL certificate error. Please contact administrator.';
}
throw createError({
statusCode: 500,
statusMessage: errorMsg,
});
}
if (!passwordResponse.ok) {
const errorText = await passwordResponse.text();
console.error('❌ Keycloak password update failed:', errorText);
db.close();
throw createError({
statusCode: passwordResponse.status,
statusMessage: `Failed to update password in Keycloak: ${errorText}`,
});
}
console.log(`✅ Password updated successfully in Keycloak for user ${userId}`);
} catch (passwordError: any) {
console.error('❌ Error updating password in Keycloak:', passwordError);
db.close();
// Re-throw if it's already a createError
if (passwordError.statusCode) {
throw passwordError;
}
throw createError({
statusCode: 500,
statusMessage: `Failed to update password: ${passwordError.message}`,
});
}
}
// Prepare update fields
const updateFields: string[] = [];
const updateValues: any[] = [];
+37 -10
View File
@@ -17,6 +17,7 @@ const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
// Create table if not exists
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -37,18 +38,44 @@ const initDb = () => {
)
`);
// Migration: Add new columns if they don't exist
// Migration: Check and add missing columns one by one
try {
db.exec(`
ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN lastLogin INTEGER;
`);
} catch (e: any) {
if (!e.message?.includes('duplicate column')) {
console.warn('Migration note:', e.message);
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const columnNames = tableInfo.map(col => col.name);
// Add missing columns one by one
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('given_name')) {
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
console.log('✅ Added column: given_name');
}
if (!columnNames.includes('family_name')) {
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
console.log('✅ Added column: family_name');
}
} catch (e: any) {
console.error('❌ Migration error:', e.message);
// Don't throw, continue with existing schema
}
return db;
+56 -5
View File
@@ -79,6 +79,47 @@ const getLastAccessFromKeycloak = async (userId: string, accessToken: string, co
}
};
// Helper to ensure database schema is up to date
const ensureSchema = (db: any) => {
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const columnNames = tableInfo.map(col => col.name);
// Add missing columns one by one
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('given_name')) {
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
console.log('✅ Added column: given_name');
}
if (!columnNames.includes('family_name')) {
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
console.log('✅ Added column: family_name');
}
} catch (e: any) {
console.error('❌ Schema migration error:', e.message);
}
};
export default defineEventHandler(async (event) => {
console.log("📋 Users list endpoint called");
@@ -108,23 +149,33 @@ export default defineEventHandler(async (event) => {
console.log("️ No valid session found, will use database values for last access");
}
// Open database connection
const db = new Database(dbPath);
// Ensure schema is up to date before querying
ensureSchema(db);
// Get all users
const users = db.prepare('SELECT * FROM users ORDER BY updatedAt DESC').all() as any[];
// Parse JSON fields and enrich with last access from Keycloak
const formattedUsers = await Promise.all(users.map(async (user) => {
// Try to get last access from Keycloak, fallback to database value
let lastLogin = user.lastLogin || null;
// Get lastLogin from database (handle null, 0, or undefined)
let lastLogin: number | null = null;
if (user.lastLogin !== null && user.lastLogin !== undefined && user.lastLogin !== 0) {
lastLogin = user.lastLogin;
}
// Only fetch from Keycloak if we have a valid user ID, access token, and config
// And only if we don't have a valid lastLogin in database
if (user.id && accessToken && config.keycloakIssuer) {
try {
const keycloakLastAccess = await getLastAccessFromKeycloak(user.id, accessToken, config);
// Use Keycloak last access if available, otherwise keep database value
// Use Keycloak last access if available and newer than database value
if (keycloakLastAccess) {
lastLogin = keycloakLastAccess;
if (!lastLogin || keycloakLastAccess > lastLogin) {
lastLogin = keycloakLastAccess;
}
}
} catch (error) {
// Silently fail and use database value
@@ -138,7 +189,7 @@ export default defineEventHandler(async (event) => {
namaUser: user.namaUser,
email: user.email,
tipeUser: user.tipeUser || '',
lastLogin: lastLogin,
lastLogin: lastLogin, // Will be null if never logged in, otherwise timestamp in seconds
roles: JSON.parse(user.roles || '[]'),
realmRoles: JSON.parse(user.realmRoles || '[]'),
accountRoles: JSON.parse(user.accountRoles || '[]'),
+401
View File
@@ -0,0 +1,401 @@
// server/api/users/sync-all.post.ts
// Sync all users from Keycloak Admin API to database
// This endpoint will fetch all users from Keycloak and sync them to the database
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
// Helper to get database path
const getDbPath = () => {
const dbDir = join(process.cwd(), 'data');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'users.db');
};
// Helper to decode JWT token payload
const decodeTokenPayload = (token: string | undefined): any | null => {
if (!token) return null;
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payloadBase64 = parts[1];
return JSON.parse(Buffer.from(payloadBase64, "base64").toString());
} catch (e) {
return null;
}
};
// Helper to get last access from Keycloak for a user
const getLastAccessFromKeycloak = async (userId: string, accessToken: string, config: any): Promise<number | null> => {
try {
if (!accessToken) {
return null;
}
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const sessionsUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}/users/${userId}/sessions`;
const sessionsResponse = await fetch(sessionsUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!sessionsResponse.ok) {
return null;
}
const sessions = await sessionsResponse.json() as any[];
if (!sessions || sessions.length === 0) {
return null;
}
let lastAccessTimestamp = 0;
sessions.forEach(session => {
if (session.lastAccess && session.lastAccess > lastAccessTimestamp) {
lastAccessTimestamp = session.lastAccess;
}
});
return lastAccessTimestamp > 0 ? Math.floor(lastAccessTimestamp / 1000) : null;
} catch (error: any) {
console.warn(`⚠️ Error fetching last access for user ${userId}:`, error.message);
return null;
}
};
// Helper to get user from Keycloak Admin API
const getUserFromKeycloak = async (userId: string, accessToken: string, config: any): Promise<any | null> => {
try {
if (!accessToken) {
return null;
}
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const userUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}/users/${userId}`;
const userResponse = await fetch(userUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!userResponse.ok) {
return null;
}
return await userResponse.json();
} catch (error: any) {
console.warn(`⚠️ Error fetching user ${userId} from Keycloak:`, error.message);
return null;
}
};
// Helper to get all users from Keycloak Admin API
const getAllUsersFromKeycloak = async (accessToken: string, config: any): Promise<any[]> => {
try {
if (!accessToken) {
return [];
}
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
const usersUrl = `${config.keycloakIssuer.replace('/realms/' + realm, '')}/admin/realms/${realm}/users?max=1000`;
const usersResponse = await fetch(usersUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!usersResponse.ok) {
console.warn('⚠️ Failed to fetch users from Keycloak:', usersResponse.status);
return [];
}
return await usersResponse.json();
} catch (error: any) {
console.warn(`⚠️ Error fetching all users from Keycloak:`, error.message);
return [];
}
};
// Initialize database
const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
// Create table if not exists
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
namaLengkap TEXT NOT NULL,
namaUser TEXT UNIQUE NOT NULL,
email TEXT,
tipeUser TEXT DEFAULT '',
lastLogin INTEGER,
roles TEXT DEFAULT '[]',
realmRoles TEXT DEFAULT '[]',
accountRoles TEXT DEFAULT '[]',
resourceRoles TEXT DEFAULT '[]',
groups TEXT DEFAULT '[]',
given_name TEXT,
family_name TEXT,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
)
`);
// Migration: Check and add missing columns one by one
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const columnNames = tableInfo.map(col => col.name);
// Add missing columns one by one
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('given_name')) {
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
console.log('✅ Added column: given_name');
}
if (!columnNames.includes('family_name')) {
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
console.log('✅ Added column: family_name');
}
} catch (e: any) {
console.error('❌ Migration error:', e.message);
// Don't throw, continue with existing schema
}
return db;
};
export default defineEventHandler(async (event) => {
console.log("🔄 Sync all users endpoint called");
const sessionCookie = getCookie(event, "user_session");
if (!sessionCookie) {
throw createError({
statusCode: 401,
statusMessage: "No session cookie found",
});
}
try {
const config = useRuntimeConfig();
const session = JSON.parse(sessionCookie);
const isExpired = Date.now() > session.expiresAt;
if (isExpired) {
deleteCookie(event, "user_session");
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
const accessToken = session.accessToken;
if (!accessToken) {
throw createError({
statusCode: 401,
statusMessage: "No access token found",
});
}
// Get all users from Keycloak
console.log("📥 Fetching all users from Keycloak...");
const keycloakUsers = await getAllUsersFromKeycloak(accessToken, config);
console.log(`✅ Found ${keycloakUsers.length} users in Keycloak`);
const db = initDb();
let createdCount = 0;
let updatedCount = 0;
let unchangedCount = 0;
// Sync each user
for (const kcUser of keycloakUsers) {
try {
const userId = kcUser.id;
const namaLengkap = kcUser.firstName && kcUser.lastName
? `${kcUser.firstName} ${kcUser.lastName}`.trim()
: kcUser.firstName || kcUser.lastName || kcUser.username || '';
const namaUser = kcUser.username || kcUser.email?.split('@')[0] || '';
const email = kcUser.email || null;
const given_name = kcUser.firstName || null;
const family_name = kcUser.lastName || null;
if (!userId || !namaUser) {
console.warn(`⚠️ Skipping user with missing ID or username: ${userId}`);
continue;
}
// Get user details from Keycloak (including roles and groups)
const userDetails = await getUserFromKeycloak(userId, accessToken, config);
// Extract roles and groups
const realmRoles = userDetails?.realmRoles || [];
const groups = userDetails?.groups || [];
// Determine tipeUser from groups
let tipeUser = '';
if (Array.isArray(groups) && groups.length > 0) {
const lastGroup = groups[groups.length - 1];
if (typeof lastGroup === 'string') {
const parts = lastGroup.split('/').filter(Boolean);
if (parts.length > 0) {
tipeUser = parts[parts.length - 1];
}
}
}
// Get last access from Keycloak
const lastLogin = await getLastAccessFromKeycloak(userId, accessToken, config);
// Check if user exists in database
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
const rolesJson = JSON.stringify(realmRoles);
const realmRolesJson = JSON.stringify(realmRoles);
const accountRolesJson = JSON.stringify([]);
const resourceRolesJson = JSON.stringify([]);
const groupsJson = JSON.stringify(groups);
if (existingUser) {
// Update existing user - only update if there are changes
const needsUpdate =
existingUser.namaLengkap !== namaLengkap ||
existingUser.namaUser !== namaUser ||
existingUser.email !== email ||
existingUser.roles !== rolesJson ||
existingUser.realmRoles !== realmRolesJson ||
existingUser.groups !== groupsJson ||
existingUser.given_name !== given_name ||
existingUser.family_name !== family_name ||
(existingUser.tipeUser === '' && tipeUser !== '') ||
(lastLogin && existingUser.lastLogin !== lastLogin);
if (needsUpdate) {
const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser;
const updateLastLogin = lastLogin || existingUser.lastLogin;
db.prepare(`
UPDATE users
SET namaLengkap = ?,
namaUser = ?,
email = ?,
roles = ?,
realmRoles = ?,
accountRoles = ?,
resourceRoles = ?,
groups = ?,
given_name = ?,
family_name = ?,
tipeUser = ?,
lastLogin = ?,
updatedAt = strftime('%s', 'now')
WHERE id = ?
`).run(
namaLengkap,
namaUser,
email || null,
rolesJson,
realmRolesJson,
accountRolesJson,
resourceRolesJson,
groupsJson,
given_name,
family_name,
updateTipeUser,
updateLastLogin,
userId
);
updatedCount++;
console.log(`✅ Updated user: ${namaUser}`);
} else {
unchangedCount++;
}
} else {
// Insert new user
db.prepare(`
INSERT INTO users (
id, namaLengkap, namaUser, email, roles, realmRoles, accountRoles, resourceRoles, groups,
given_name, family_name, tipeUser, lastLogin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
namaLengkap,
namaUser,
email || null,
rolesJson,
realmRolesJson,
accountRolesJson,
resourceRolesJson,
groupsJson,
given_name,
family_name,
tipeUser,
lastLogin
);
createdCount++;
console.log(`✅ Created new user: ${namaUser}`);
}
} catch (userError: any) {
console.error(`❌ Error syncing user ${kcUser.id}:`, userError.message);
// Continue with next user
}
}
db.close();
console.log(`✅ Sync completed: ${createdCount} created, ${updatedCount} updated, ${unchangedCount} unchanged`);
return {
success: true,
message: 'All users synced successfully',
stats: {
created: createdCount,
updated: updatedCount,
unchanged: unchangedCount,
total: keycloakUsers.length
}
};
} catch (error: any) {
console.error("❌ Error syncing all users:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to sync all users",
});
}
});
+7
View File
@@ -30,8 +30,15 @@ export default defineEventHandler(async (event) => {
// Use session createdAt as loginTime, or current time if not available
const { syncUserFromTokens } = await import('~/server/utils/userSync');
const loginTime = session.createdAt || Date.now();
console.log("🔄 Syncing user from session...");
console.log(" Session createdAt:", session.createdAt);
console.log(" Login time:", loginTime);
const result = syncUserFromTokens(session.idToken, session.accessToken, loginTime);
console.log(`✅ Sync result: ${result.action} - ${result.message}`);
return result;
} catch (error: any) {
console.error("❌ Error syncing user:", error);
+63 -28
View File
@@ -19,6 +19,7 @@ const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
// Create table if not exists
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -39,35 +40,44 @@ const initDb = () => {
)
`);
// Migration: Add new columns if they don't exist (for existing databases)
// Migration: Check and add missing columns one by one
try {
db.exec(`
ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN lastLogin INTEGER;
`);
} catch (e: any) {
// Columns might already exist, ignore error
if (!e.message?.includes('duplicate column')) {
console.warn('Migration note:', e.message);
}
}
// Migration: Rename keterangan to lastLogin if exists
try {
// Check if keterangan column exists
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const hasKeterangan = tableInfo.some(col => col.name === 'keterangan');
const hasLastLogin = tableInfo.some(col => col.name === 'lastLogin');
const columnNames = tableInfo.map(col => col.name);
if (hasKeterangan && !hasLastLogin) {
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
// For now, we'll just add lastLogin and leave keterangan (it will be ignored)
console.log('Migration: Adding lastLogin column');
// Add missing columns one by one
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('given_name')) {
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
console.log('✅ Added column: given_name');
}
if (!columnNames.includes('family_name')) {
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
console.log('✅ Added column: family_name');
}
} catch (e: any) {
console.warn('Migration check note:', e.message);
console.error('Migration error:', e.message);
// Don't throw, continue with existing schema
}
return db;
@@ -207,7 +217,15 @@ export const syncUserFromTokens = (
existingUser.family_name !== (idTokenPayload.family_name || null) ||
(existingUser.tipeUser === '' && tipeUser !== ''); // Only update if empty
if (needsUpdate) {
// Always update lastLogin when loginTime is provided (user is logging in)
// Check if lastLogin needs updating (if loginTime provided or new timestamp is newer)
const existingLastLogin = existingUser.lastLogin || 0;
const needsLastLoginUpdate = loginTime !== undefined
? true // Always update on login when loginTime is provided
: (lastLoginTimestamp > existingLastLogin); // Only update if newer when not a login event
// Update if any data changed OR if lastLogin needs updating
if (needsUpdate || needsLastLoginUpdate) {
// Update user data
// Only update tipeUser if it's currently empty (preserve manual edits)
const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser;
@@ -244,7 +262,11 @@ export const syncUserFromTokens = (
userId
);
console.log("✅ User data updated:", userId);
if (needsLastLoginUpdate && !needsUpdate) {
console.log("✅ User lastLogin updated:", userId, "new timestamp:", lastLoginTimestamp);
} else {
console.log("✅ User data updated:", userId);
}
db.close();
return {
success: true,
@@ -262,6 +284,14 @@ export const syncUserFromTokens = (
}
} else {
// New user - insert
console.log(" Inserting new user:", {
userId,
namaLengkap,
namaUser,
email,
lastLoginTimestamp
});
db.prepare(`
INSERT INTO users (
id, namaLengkap, namaUser, email, roles, realmRoles, accountRoles, resourceRoles, groups,
@@ -283,12 +313,17 @@ export const syncUserFromTokens = (
lastLoginTimestamp // lastLogin - from session createdAt
);
console.log("✅ New user saved:", userId);
console.log("✅ New user saved to database:", {
userId,
namaUser,
namaLengkap,
lastLogin: lastLoginTimestamp ? new Date(lastLoginTimestamp * 1000).toISOString() : 'null'
});
db.close();
return {
success: true,
action: 'created',
message: 'New user saved successfully'
message: `New user ${namaUser} saved successfully`
};
}
} catch (error: any) {