update user login baru dan hakakses
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '[]'),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user