update user login baru dan hakakses

This commit is contained in:
Fanrouver
2025-12-16 10:42:45 +07:00
parent 78de0418e1
commit d2a51f3aee
24 changed files with 2606 additions and 189 deletions
+6
View File
@@ -6,6 +6,12 @@
.cache
dist
# Database files
data/
*.db
*.sqlite
*.sqlite3
# Node dependencies
node_modules
+94
View File
@@ -0,0 +1,94 @@
# Pages List - Web Antrean Application
This document lists all pages in the application with their file paths and route paths.
## Root Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Home/Index | `pages/index.vue` | `/` |
| Login Page | `pages/LoginPage.vue` | `/login-page` |
| Dashboard | `pages/Dashboard.vue` | `/dashboard` |
| Admin Klinik | `pages/AdminKlinik.vue` | `/admin-klinik` |
| Admin Loket | `pages/AdminLoket.vue` | `/admin-loket` |
| Admin Penunjang | `pages/AdminPenunjang.vue` | `/admin-penunjang` |
| Buat Antrean | `pages/BuatAntrean.vue` | `/buat-antrean` |
| Klinik Ruang Admin | `pages/KlinikRuangAdmin.vue` | `/klinik-ruang-admin` |
| Ranap Admin | `pages/RanapAdmin.vue` | `/ranap-admin` |
## Anjungan Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Anjungan | `pages/Anjungan/Anjungan.vue` | `/anjungan` |
| Admin Anjungan | `pages/Anjungan/AdminAnjungan.vue` | `/anjungan/admin-anjungan` |
| Antrian Klinik | `pages/Anjungan/AntrianKlinik.vue` | `/anjungan/antrian-klinik` |
| Antrian Klinik Ruang | `pages/Anjungan/AntrianKlinikRuang.vue` | `/anjungan/antrian-klinik-ruang` |
| Antrian Penunjang | `pages/Anjungan/AntrianPenunjang.vue` | `/anjungan/antrian-penunjang` |
## Check In Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Check In | `pages/CheckInPasien/checkIn.vue` | `/check-in-pasien/check-in` |
## Data Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Data Pasien Index | `pages/data-pasien/index.vue` | `/data-pasien` |
| Edit Data Pasien | `pages/data-pasien/edit/[id].vue` | `/data-pasien/edit/:id` |
## Monitoring Pasien Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Monitoring Pasien | `pages/MonitoringPasien/monitoringPasien.vue` | `/monitoring-pasien/monitoring-pasien` |
| Detail Pasien | `pages/MonitoringPasien/pasien/[id].vue` | `/monitoring-pasien/pasien/:id` |
## Profile Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Profil | `pages/Profile/Profil.vue` | `/profile/profil` |
## Setting Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| User Login | `pages/Setting/UserLogin.vue` | `/setting/user-login` |
| Hak Akses | `pages/Setting/HakAkses.vue` | `/setting/hak-akses` |
| Master Klinik | `pages/Setting/MasterKlinik.vue` | `/setting/master-klinik` |
| Master Klinik Ruang | `pages/Setting/MasterKlinikRuang.vue` | `/setting/master-klinik-ruang` |
| Master Loket | `pages/Setting/MasterLoket.vue` | `/setting/master-loket` |
| Master Penunjang | `pages/Setting/MasterPenunjang.vue` | `/setting/master-penunjang` |
| Screen | `pages/Setting/Screen.vue` | `/setting/screen` |
| Tambah Loket | `pages/Setting/TambahLoket.vue` | `/setting/tambah-loket` |
| Edit Loket | `pages/Setting/Edit-Loket/[id].vue` | `/setting/edit-loket/:id` |
| Screen Index | `pages/Setting/Screen/index.vue` | `/setting/screen` |
| Edit Screen | `pages/Setting/Screen/edit/[id].vue` | `/setting/screen/edit/:id` |
## Verifikasi Akun Pages
| Page Name | File Path | Route Path |
|-----------|-----------|------------|
| Verifikasi Akun | `pages/verifikasiAkun/VerifikasiAkun.vue` | `/verifikasi-akun/verifikasi-akun` |
---
## Summary
- **Total Pages**: 30+ pages
- **Dynamic Routes**: 4 pages with `[id]` parameter
- **Note**: `Pengaturan.vue.txt` appears to be a text file, not an active Vue component
## Route Path Conversion Rules
In Nuxt.js, file paths are converted to routes as follows:
- File names are converted to kebab-case
- Folders create nested routes
- `index.vue` files create routes at the folder level
- `[id].vue` files create dynamic routes with `:id` parameter
- Special characters like hyphens are preserved
+49 -4
View File
@@ -1,7 +1,15 @@
<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 | {{ localItem.namaTipeUser }}
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-title>
<v-card-text>
<v-row>
@@ -20,7 +28,7 @@
</tr>
</thead>
<tbody>
<tr v-for="(menu, index) in localItem.hakAksesMenu" :key="menu.name">
<tr v-for="(menu, index) in orderedMenus" :key="menu.name">
<td>{{ index + 1 }}</td>
<td>{{ menu.name }}</td>
<td class="text-center">
@@ -66,7 +74,8 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useNavItemsStore } from '~/stores/navItems1';
// Define types for better readability and safety
interface HakAksesMenu {
@@ -80,6 +89,8 @@ interface HakAksesMenu {
interface HakAksesData {
id: number;
role?: string;
group?: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
@@ -91,7 +102,7 @@ const props = defineProps({
required: true,
// Add custom validator for more robust checks
validator: (value: HakAksesData) => {
return 'namaTipeUser' in value && 'hakAksesMenu' in value;
return 'hakAksesMenu' in value;
},
},
});
@@ -102,6 +113,40 @@ 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 navItemsStore = useNavItemsStore();
interface NavItemOrder {
name: string;
children?: NavItemOrder[];
}
// Build a map of menu order based on the sidebar configuration so the list is always aligned
const menuOrder = computed(() => {
const order: Record<string, number> = {};
const walk = (items: NavItemOrder[], startIndex = 0): number => {
let idx = startIndex;
items.forEach((item) => {
order[item.name] = idx;
idx += 1;
if (item.children?.length) {
idx = walk(item.children, idx);
}
});
return idx;
};
walk(navItemsStore.navItems);
return order;
});
const orderedMenus = computed(() => {
const order = menuOrder.value;
return [...localItem.value.hakAksesMenu].sort((a, b) => {
const orderA = order[a.name] ?? Number.MAX_SAFE_INTEGER;
const orderB = order[b.name] ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
});
// Watch the prop for changes and update the local copy
watch(() => props.item, (newItem) => {
localItem.value = JSON.parse(JSON.stringify(newItem));
+186 -2
View File
@@ -1,7 +1,7 @@
<template>
<v-app id="inspire">
<SideBar
:items="navItemsStore.navItems"
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
@@ -14,10 +14,12 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useLocalStorage } from "@vueuse/core";
import SideBar from "../components/layout/SideBar.vue";
// Ensure this path matches your store location
import { useNavItemsStore } from '~/stores/navItems1';
import { useAuth } from "~/composables/useAuth";
definePageMeta({
middleware: 'auth'
@@ -28,6 +30,188 @@ const drawer = ref(true);
const rail = ref(true);
const navItemsStore = useNavItemsStore();
const { user, checkAuth } = useAuth();
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;
}
// Cache for API permissions
const apiPermissions = ref<BackendPermission[]>([]);
const currentUserRoles = ref<string[]>([]);
const currentUserGroups = ref<string[]>([]);
// Get current user data with roles and groups
const fetchCurrentUserData = async () => {
try {
const userData = await $fetch('/api/users/current');
currentUserRoles.value = [
...(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]);
}
});
currentUserGroups.value = groups;
return { roles: currentUserRoles.value, groups: currentUserGroups.value };
} catch (error) {
console.error('Error fetching current user data:', error);
return { roles: [], groups: [] };
}
};
// Fetch permissions from backend API
const fetchPermissionsFromAPI = async () => {
const { roles, groups } = await fetchCurrentUserData();
if (roles.length === 0 || groups.length === 0) {
console.warn('No roles or groups found for current user');
return;
}
// Use first role and first group (or combine as needed)
const primaryRole = roles[0] || '';
const primaryGroup = groups[0] || '';
if (!primaryRole || !primaryGroup) {
return;
}
try {
const response = await $fetch<PermissionResponse>('/api/permission', {
query: {
roles: primaryRole,
groups: primaryGroup,
},
});
if (response && response.data && Array.isArray(response.data)) {
apiPermissions.value = response.data;
}
} catch (error) {
console.error('Error fetching permissions from API:', error);
// Fallback to local storage if API fails
apiPermissions.value = [];
}
};
const filteredNavItems = computed(() => {
// If no API permissions, check local storage as fallback
if (apiPermissions.value.length === 0) {
const hakAksesData = useLocalStorage<any[]>('allHakAksesData', []);
const roleCandidates = [
...(user.value?.roles || []),
...(user.value?.realm_access?.roles || []),
].map((role) => role.toLowerCase());
const localPermission = hakAksesData.value.find((item) =>
roleCandidates.includes(item.role?.toLowerCase() || item.namaTipeUser?.toLowerCase())
);
if (localPermission) {
const permissionMap = new Map(
localPermission.hakAksesMenu.map((menu: any) => [menu.name.toLowerCase(), menu])
);
const applyFilter = (items: NavItem[]): NavItem[] => {
return items
.map((item) => {
const menuPerm = permissionMap.get(item.name.toLowerCase());
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = menuPerm ? menuPerm.canAccess : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
}
// If no permissions found, show all items
return navItemsStore.navItems;
}
// Use API permissions to filter
const permissionMap = new Map(
apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm])
);
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())
);
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
return {
...item,
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
};
return applyFilter(navItemsStore.navItems);
});
onMounted(async () => {
await checkAuth();
await fetchPermissionsFromAPI();
});
</script>
<style scoped>
+18 -8
View File
@@ -1,13 +1,27 @@
import { defineNuxtRouteMiddleware, navigateTo } from '#app';
import type { RouteLocationNormalized } from 'vue-router';
// Import the useAuth composable (it will be auto-imported, but this helps TypeScript/VS Code)
import { useAuth } from '~/composables/useAuth';
// NOTE: We don't need the interfaces here anymore, as useAuth handles the typing.
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
console.log('🛡️ Auth middleware triggered for:', to.path);
// Check for bypass flag (from keyboard shortcut)
if (process.client) {
const bypassFlag = sessionStorage.getItem('bypassRootRedirect');
if (bypassFlag === 'true' && to.path === '/') {
console.log('🔑 Bypass flag detected - allowing root access');
// Clear the flag immediately to prevent future bypasses
sessionStorage.removeItem('bypassRootRedirect');
return; // Allow access without any redirect
}
}
// Redirect root path to LoginPage
if (to.path === '/') {
console.log('🔄 Redirecting from root to LoginPage');
return navigateTo('/LoginPage', { replace: true });
}
// Allow the login page to handle its own checks without redirection loops
if (to.path === '/LoginPage') {
console.log('⏭️ Allowing access to LoginPage');
@@ -15,10 +29,8 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
}
// Skip auth check if it's the development server side render pass.
// Although we still recommend using the checkAuth() composable for better SSR handling.
if (process.server && process.env.NODE_ENV === 'development') {
console.log('⏭️ Skipping intensive check on server-side during development');
// Using the composable here ensures it is registered correctly, even if we skip the main check
useAuth();
return;
}
@@ -35,7 +47,6 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
const { checkAuth } = useAuth();
console.log('🔍 Checking authentication status using useAuth...');
// Use the composable's function to check the session
const user = await checkAuth();
if (user) {
@@ -43,7 +54,6 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
return;
} else {
console.log('❌ No valid session found, redirecting to login');
// If checkAuth fails, it already cleared the user state. Redirect.
return navigateTo('/LoginPage');
}
} catch (error) {
@@ -51,4 +61,4 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) =>
console.log('🔄 Redirecting to login due to error');
return navigateTo('/LoginPage');
}
});
});
+6 -4
View File
@@ -49,7 +49,8 @@ export default defineNuxtConfig({
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
keycloakIssuer: process.env.KEYCLOAK_ISSUER,
public: {
authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
},
},
@@ -63,11 +64,12 @@ export default defineNuxtConfig({
"~/assets/scss/main.scss",
],
devServer: {
host: "10.10.150.114",
port: 3001,
host: "http://10.10.150.175", // Changed from "10.10.123.139"
port: 3001
},
// 'http://10.10.150.114:3001/'
// // 'http://10.10.150.114:3001/'
// "http://10.10.150.175:3001"
vite: {
css: {
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "nuxt build",
"_command_dev": "nuxt dev -o --host --port 3001",
"_command_dev2": "nuxt dev -o --port 3001",
"dev": "nuxt dev -o --port 3001",
"_command_dev3": "nuxt dev -o --host 10.10.150.175 --port 3001",
"dev": "nuxt dev -o",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
+14 -58
View File
@@ -166,7 +166,6 @@
</v-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="realtimeLineData"
:options="lineOptions"
style="height: 350px"
@@ -200,6 +199,7 @@
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import { useAuth } from '~/composables/useAuth';
import dayjs from 'dayjs';
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
@@ -228,10 +228,9 @@ definePageMeta({
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
const user = ref({ name: 'Admin User', preferred_username: 'admin' });
const user = ref(null);
const isLoading = ref(false);
const checkAuth = async () => user.value;
const navigateTo = (path) => console.log('Navigate to:', path);
const { checkAuth } = useAuth();
const exportType = ref('JSON');
const exportOptions = ref([
@@ -254,10 +253,8 @@ const mockQueueData = ref([
{ date: '2025-10-18', registrants: 435, attendees: 380 },
]);
// --- OPTIMIZED REALTIME CHART LOGIC ---
const realtimeChart = ref(null);
// --- SIMPLIFIED REALTIME CHART (NO AUTO-UPDATE) ---
const maxDataPoints = 10;
let realtimeInterval = null;
const realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
@@ -282,7 +279,7 @@ const realtimeLineData = ref({
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 }, // Disable animation for better performance
animation: { duration: 0 },
plugins: {
legend: { display: true },
title: { display: false }
@@ -300,67 +297,26 @@ const lineOptions = ref({
}
}
});
// --- END SIMPLIFIED REALTIME CHART ---
const updateRealtimeData = () => {
if (!realtimeChart.value) return; // Guard clause
const chart = realtimeChart.value.chart;
if (!chart) return;
// Direct chart data manipulation (more efficient)
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
const newDataPoint = Math.floor(Math.random() * 20) + 40;
const newTimeLabel = dayjs().format('HH:mm:ss');
chart.data.labels.push(newTimeLabel);
chart.data.datasets[0].data.push(newDataPoint);
chart.update('none'); // Update without animation
};
// Start/stop interval management
const startRealtimeUpdates = () => {
if (realtimeInterval) return; // Prevent multiple intervals
realtimeInterval = setInterval(updateRealtimeData, 3000); // Update every 3 seconds
};
const stopRealtimeUpdates = () => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
realtimeInterval = null;
}
};
// Lifecycle hooks
onMounted(() => {
startRealtimeUpdates();
});
onUnmounted(() => {
stopRealtimeUpdates();
});
// --- END OPTIMIZED REALTIME CHART LOGIC ---
// Single onMounted
onMounted(async () => {
console.log('📊 Dashboard mounted');
try {
const sessionUser = await checkAuth()
const sessionUser = await checkAuth();
if (sessionUser) {
user.value = sessionUser;
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
realtimeInterval = setInterval(updateRealtimeData, 5000);
} else {
await navigateTo('/LoginPage');
console.log('✅ Dashboard loaded successfully');
}
} catch (error) {
console.error('Auth check error:', error);
await navigateTo('/LoginPage');
console.error('Auth check error:', error);
}
});
// Clean onUnmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
}
console.log('🧹 Dashboard unmounting');
});
const changeYear = (year) => {
+3 -3
View File
@@ -43,9 +43,9 @@
<v-col cols="12" sm="3">
<v-text-field label="No. RM" v-model="filters.rm_number" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="3">
<!-- <v-col cols="12" sm="3">
<v-text-field label="No. Kode QR" v-model="filters.qr_code" variant="outlined" density="compact" hide-details></v-text-field>
</v-col>
</v-col> -->
<v-col cols="12" sm="2">
<v-text-field label="No. Antrean" v-model="filters.queue_number" variant="outlined" density="compact" hide-details type="number"></v-text-field>
</v-col>
@@ -101,7 +101,7 @@ const activeTab = ref('all');
// Filter Model: Menampung input dari form pencarian
const filters = ref({
rm_number: '',
qr_code: '',
// qr_code: '',
queue_number: '', // Filter baru
service: null, // Filter Layanan
});
+115 -24
View File
@@ -1,3 +1,4 @@
<!-- pages/Profile/Profil.vue -->
<template>
<v-container fluid class="pa-0">
<!-- Hero Header Section -->
@@ -22,8 +23,8 @@
<div class="profile-avatar-container">
<v-avatar size="140" class="profile-avatar elevation-8">
<v-img
:src="profileData.picture || 'https://i.pravatar.cc/300?img=68'"
alt="Profile Picture"
:src="user?.picture || 'https://i.pravatar.cc/300?img=68'"
:alt="`${user?.name || 'User'} Profile`"
></v-img>
</v-avatar>
<v-btn
@@ -37,9 +38,15 @@
</v-btn>
</div>
<h2 class="text-h5 font-weight-bold mb-2">{{ profileData.name }}</h2>
<p class="text-body-2 text-grey-darken-1 mb-1">@{{ profileData.username }}</p>
<p class="text-body-2 text-grey mb-4">{{ profileData.email }}</p>
<h2 class="text-h5 font-weight-bold mb-2">
{{ user?.name || user?.preferred_username || 'User' }}
</h2>
<p class="text-body-2 text-grey-darken-1 mb-1">
@{{ user?.preferred_username || profileData.username }}
</p>
<p class="text-body-2 text-grey mb-4">
{{ user?.email || 'No email' }}
</p>
<v-chip
color="success"
@@ -57,15 +64,21 @@
<div class="text-left px-6">
<div class="info-item mb-3">
<v-icon size="20" color="blue-darken-2" class="mr-2">mdi-identifier</v-icon>
<span class="text-body-2 text-grey-darken-1">ID: {{ profileData.id }}</span>
<span class="text-body-2 text-grey-darken-1">
ID: {{ user?.id ? user.id.substring(0, 12) : 'N/A' }}
</span>
</div>
<div class="info-item mb-3">
<v-icon size="20" color="orange-darken-2" class="mr-2">mdi-calendar-check</v-icon>
<span class="text-body-2 text-grey-darken-1">Bergabung: {{ profileData.joinDate }}</span>
<span class="text-body-2 text-grey-darken-1">
Bergabung: {{ profileData.joinDate }}
</span>
</div>
<div class="info-item">
<v-icon size="20" color="green-darken-2" class="mr-2">mdi-clock-outline</v-icon>
<span class="text-body-2 text-grey-darken-1">Login: {{ profileData.lastLogin }}</span>
<span class="text-body-2 text-grey-darken-1">
Login: {{ profileData.lastLogin }}
</span>
</div>
</div>
</div>
@@ -315,7 +328,7 @@
</v-row>
</v-container>
Change Password Dialog
<!-- Change Password Dialog -->
<v-dialog v-model="passwordDialog" max-width="500" persistent>
<v-card class="rounded-xl">
<v-card-title class="pa-6 pb-4">
@@ -478,7 +491,7 @@
<v-card-text class="pa-6 text-center">
<v-avatar size="150" class="mb-4">
<v-img :src="profileData.picture"></v-img>
<v-img :src="user?.picture || 'https://i.pravatar.cc/300?img=68'"></v-img>
</v-avatar>
<v-file-input
label="Pilih foto baru"
@@ -544,13 +557,18 @@
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { navigateTo } from '#app';
// Explicitly import useAuth composable
import { useAuth } from '~/composables/useAuth';
definePageMeta({
middleware: 'auth'
});
// Get user data from your custom useAuth composable
const { user, isLoading: authLoading, checkAuth } = useAuth();
const isEditing = ref(false);
const isSaving = ref(false);
const isChangingPassword = ref(false);
@@ -570,16 +588,17 @@ const snackbarIcon = computed(() => {
return snackbarColor.value === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle';
});
// Initialize profile data with user info
const profileData = reactive({
id: 'USR-12345678',
name: 'John Doe',
username: 'johndoe',
email: '[email protected]',
phone: '+62 812 3456 7890',
bio: 'Passionate developer and tech enthusiast. Love building beautiful and functional web applications.',
picture: 'https://i.pravatar.cc/300?img=68',
id: '',
name: '',
username: '',
email: '',
phone: '',
bio: '',
picture: '',
joinDate: '15 Januari 2024',
lastLogin: '16 Oktober 2025, 10:30 AM'
lastLogin: 'Memuat...' // Will be updated from database
});
const passwordData = reactive({
@@ -614,6 +633,75 @@ const activeSessions = ref([
const originalData = ref({});
// Format last login time
const formatLastLogin = (timestamp) => {
if (!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');
};
// Fetch user data from database
const fetchUserData = async () => {
try {
const userData = await $fetch('/api/users/current');
if (userData) {
// Get lastLogin from database
const users = await $fetch('/api/users/list');
const dbUser = users.find(u => u.id === userData.id);
if (dbUser && dbUser.lastLogin) {
profileData.lastLogin = formatLastLogin(dbUser.lastLogin);
} else {
// Fallback to current time if not found
profileData.lastLogin = formatLastLogin(Math.floor(Date.now() / 1000));
}
}
} catch (error) {
console.error('Failed to fetch user data:', error);
// Fallback to current time on error
profileData.lastLogin = formatLastLogin(Math.floor(Date.now() / 1000));
}
};
// Sync profile data with user data
const syncUserData = () => {
if (user.value) {
profileData.id = user.value.id || '';
profileData.name = user.value.name || user.value.preferred_username || '';
profileData.username = user.value.preferred_username || user.value.email?.split('@')[0] || '';
profileData.email = user.value.email || '';
profileData.picture = user.value.picture || 'https://i.pravatar.cc/300?img=68';
// phone and bio can be loaded from additional user data if available
profileData.phone = user.value.phone_number || '';
profileData.bio = user.value.bio || '';
}
};
// Watch for user changes and sync
watch(user, () => {
syncUserData();
}, { immediate: true });
onMounted(async () => {
// Check authentication status on mount
await checkAuth();
syncUserData();
// Fetch lastLogin from database
await fetchUserData();
originalData.value = { ...profileData };
});
const startEdit = () => {
originalData.value = { ...profileData };
isEditing.value = true;
@@ -630,10 +718,16 @@ const saveProfile = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO: Replace with actual API call
// TODO: Replace with actual API call to update user profile
// await $fetch('/api/profile/update', {
// method: 'PUT',
// body: profileData
// body: {
// name: profileData.name,
// username: profileData.username,
// email: profileData.email,
// phone: profileData.phone,
// bio: profileData.bio
// }
// });
originalData.value = { ...profileData };
@@ -714,9 +808,6 @@ const toggleTwoFactor = () => {
snackbarColor.value = 'info';
snackbar.value = true;
};
// Initialize original data
originalData.value = { ...profileData };
</script>
<style scoped>
+731 -35
View File
@@ -14,8 +14,96 @@
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-col cols="12" md="6">
<v-label class="font-weight-bold text-medium-emphasis">Pilih User (Optional)</v-label>
<v-select
v-model="selectedUserId"
:items="availableUsers"
item-title="namaLengkap"
item-value="id"
placeholder="Pilih User untuk auto-fill data"
variant="outlined"
density="comfortable"
class="mt-1"
@update:model-value="fillUserData"
clearable
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="`${item.raw.namaLengkap} (${item.raw.namaUser})`" :subtitle="item.raw.tipeUser"></v-list-item>
</template>
</v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">User ID</v-label>
<v-text-field
v-model="editedItem.userId"
placeholder="User ID"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
placeholder="Nama Lengkap"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
placeholder="Nama User"
variant="outlined"
density="comfortable"
class="mt-1"
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Tipe User</v-label>
<v-select
v-model="selectedTipeUser"
:items="availableTipeUsers"
placeholder="Pilih Tipe User"
variant="outlined"
density="comfortable"
class="mt-1"
@update:model-value="fillUserDataByTipeUser"
clearable
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Role</v-label>
<v-select
v-model="editedItem.role"
:items="availableRoles"
placeholder="Pilih Role"
variant="outlined"
density="comfortable"
class="mt-1"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Group</v-label>
<v-select
v-model="editedItem.group"
:items="availableGroups"
placeholder="Pilih Group"
variant="outlined"
density="comfortable"
class="mt-1"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User (Optional)</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
placeholder="Masukkan Nama Tipe User"
@@ -25,6 +113,130 @@
></v-text-field>
</v-col>
</v-row>
<v-row v-if="viewMode === 'add' || viewMode === 'editName'">
<v-col cols="12">
<v-btn
color="info"
prepend-icon="mdi-download"
variant="outlined"
rounded="lg"
class="text-capitalize"
@click="fetchPermissionsFromBackend"
:loading="isFetchingPermissions"
:disabled="!editedItem.role || !editedItem.group"
>
Ambil Data dari Backend API
</v-btn>
<span class="ml-3 text-caption text-medium-emphasis">
* Klik tombol ini untuk mengambil permissions dari backend berdasarkan Role dan Group yang sudah diisi
</span>
</v-col>
</v-row>
<!-- Display fetched permissions info -->
<v-row v-if="fetchedBackendData && fetchedBackendData.length > 0" class="mt-4">
<v-col cols="12">
<v-card variant="outlined" class="pa-4 bg-blue-lighten-5">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-3">
<v-icon icon="mdi-information" class="mr-2" color="info"></v-icon>
Data dari Backend API
</v-card-title>
<v-card-text class="pa-0">
<div class="mb-3">
<strong>Total Permissions:</strong> {{ fetchedBackendData.length }} items
</div>
<v-table density="compact" class="elevation-1">
<thead>
<tr>
<th class="text-left">No</th>
<th class="text-left">Page Name</th>
<th class="text-center">Active</th>
<th class="text-center">Read</th>
<th class="text-center">Create</th>
<th class="text-center">Update</th>
<th class="text-center">Delete</th>
<th class="text-center">Level</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(perm, index) in fetchedBackendData" :key="perm.id">
<td>{{ index + 1 }}</td>
<td>
<strong>{{ perm.pagename }}</strong>
<div class="text-caption text-grey">{{ perm.pagesID }}</div>
</td>
<td class="text-center">
<v-icon :color="perm.active ? 'green' : 'grey'">
{{ perm.active ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.read ? 'green' : 'grey'">
{{ perm.read ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.create ? 'green' : 'grey'">
{{ perm.create ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.update ? 'green' : 'grey'">
{{ perm.update ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">
<v-icon :color="perm.delete ? 'green' : 'grey'">
{{ perm.delete ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</td>
<td class="text-center">{{ perm.level || '-' }}</td>
<td class="text-center">
<v-chip
:color="getMappingStatus(perm.pagename) ? 'success' : 'warning'"
size="small"
>
{{ getMappingStatus(perm.pagename) ? 'Mapped' : 'Not Mapped' }}
</v-chip>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Display mapped permissions summary -->
<v-row v-if="editedItem.hakAksesMenu && editedItem.hakAksesMenu.length > 0" class="mt-4">
<v-col cols="12">
<v-card variant="outlined" class="pa-4 bg-green-lighten-5">
<v-card-title class="text-subtitle-1 font-weight-bold pa-0 mb-3">
<v-icon icon="mdi-check-circle" class="mr-2" color="success"></v-icon>
Permissions yang Sudah di-Mapping ke Menu
</v-card-title>
<v-card-text class="pa-0">
<div class="mb-3">
<strong>Total Menu:</strong> {{ editedItem.hakAksesMenu.length }} items
<span class="ml-4">
<strong>Dengan Akses:</strong>
{{ editedItem.hakAksesMenu.filter(m => m.canAccess).length }} items
</span>
</div>
<v-alert
type="info"
variant="tonal"
density="compact"
class="mb-3"
>
Data permissions dari backend telah di-mapping ke struktur menu sidebar.
Anda dapat mengedit permissions di tab "Edit Hak Akses" setelah menyimpan data ini.
</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-end pa-0 mt-4">
<v-btn
@@ -63,7 +275,69 @@
<v-divider class="mb-4"></v-divider>
<v-card-text class="px-0">
<v-row>
<v-col cols="12">
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">User ID</v-label>
<v-text-field
v-model="editedItem.userId"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="3">
<v-label class="font-weight-bold text-medium-emphasis">Tipe User</v-label>
<v-text-field
v-model="editedItem.tipeUser"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Role</v-label>
<v-text-field
v-model="editedItem.role"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Group</v-label>
<v-text-field
v-model="editedItem.group"
variant="outlined"
density="comfortable"
class="mt-1"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold text-medium-emphasis">Nama Tipe User</v-label>
<v-text-field
v-model="editedItem.namaTipeUser"
@@ -281,14 +555,28 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top"
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false">
Tutup
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useLocalStorage, useSessionStorage } from '@vueuse/core';
import { ref, computed, onMounted } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { VueDraggableNext } from 'vue-draggable-next';
import EditHakAkses from '@/components/HakAkses/EditHakAkses.vue';
import EditHakAkses from '@/components/HakAkses/editHakAkses.vue';
import { useNavItemsStore } from '~/stores/navItems1';
definePageMeta({
@@ -314,6 +602,12 @@ interface NavItem {
interface HakAksesData {
id: number;
userId?: string;
namaLengkap?: string;
namaUser?: string;
tipeUser?: string;
role: string;
group: string;
namaTipeUser: string;
hakAksesMenu: HakAksesMenu[];
}
@@ -326,8 +620,11 @@ interface BackendPermissionItem {
disable: boolean;
delete: boolean;
active: boolean;
page_name: string;
pageID: number;
pagename: string;
pagesID: number;
level?: number;
sort?: number;
parent?: number;
}
interface SessionData {
@@ -347,6 +644,12 @@ const draggableMenus = ref<NavItem[]>([]);
// Data table headers
const headers = ref([
{ title: 'No', key: 'id' as const },
{ title: 'User ID', key: 'userId' as const, sortable: true },
{ 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 },
{ title: 'Role', key: 'role' as const, sortable: true },
{ title: 'Group', key: 'group' as const, sortable: true },
{ title: 'Nama Tipe User', key: 'namaTipeUser' as const, sortable: true },
{ title: 'Aksi', align: 'center' as const, key: 'actions' as const, sortable: false },
]);
@@ -378,18 +681,52 @@ const page = ref(1);
const editedIndex = ref(-1);
const editedItem = ref<HakAksesData>({
id: 0,
role: '',
group: '',
namaTipeUser: '',
hakAksesMenu: [],
});
// Available roles and groups from UserLogin data
const availableRoles = ref<string[]>([]);
const availableGroups = ref<string[]>([]);
const availableUsers = ref<any[]>([]);
const availableTipeUsers = ref<string[]>([]);
const selectedUserId = ref<string | null>(null);
const selectedTipeUser = ref<string | null>(null);
const isFetchingPermissions = ref(false);
const fetchedBackendData = ref<BackendPermissionItem[]>([]);
// --- Menu management logic ---
// This is your list of all possible menus with a path and icon
const defaultMenuItems = () => ([
{ name: 'Dashboard', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/dashboard', icon: 'mdi-view-dashboard' },
{ name: 'Setting', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting', icon: 'mdi-cog' },
{ name: 'Setting / Hak Akses', canAccess: false, canView: false, canAdd: false, canEdit: false, canDelete: false, path: '/setting/hakakses', icon: 'mdi-shield-lock-outline' },
// ... and so on for all your pages
]);
const buildMenuTemplate = (items: NavItem[]): HakAksesMenu[] => {
const result: HakAksesMenu[] = [];
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(navItemsStore.navItems);
return result;
};
const mergePermissions = (base: HakAksesMenu[], existing: HakAksesMenu[]) => {
return base.map((menu) => {
const matched = existing.find((item) => item.name === menu.name);
return matched ? { ...menu, ...matched } : menu;
});
};
// Computed properties
const pageCount = computed(() => {
@@ -452,18 +789,14 @@ const viewItem = (item: HakAksesData) => {
const editItem = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
editedItem.value = { ...item };
selectedTipeUser.value = item.tipeUser || null;
viewMode.value = 'editName';
};
const editAccess = (item: HakAksesData) => {
editedIndex.value = allHakAksesData.value.findIndex(d => d.id === item.id);
const baseMenuItems = defaultMenuItems();
const existingAccess = item.hakAksesMenu;
const mergedMenuItems = baseMenuItems.map(defaultMenu => {
const existingMenu = existingAccess.find(exist => exist.name === defaultMenu.name);
return existingMenu ? { ...defaultMenu, ...existingMenu } : defaultMenu;
});
const baseMenuItems = buildMenuTemplate(navItemsStore.navItems);
const mergedMenuItems = mergePermissions(baseMenuItems, item.hakAksesMenu);
editedItem.value = {
...item,
@@ -505,34 +838,397 @@ const updateItemAccess = (updatedItem: HakAksesData) => {
const resetForm = () => {
editedItem.value = {
id: 0,
userId: '',
namaLengkap: '',
namaUser: '',
tipeUser: '',
role: '',
group: '',
namaTipeUser: '',
hakAksesMenu: navItemsStore.navItems.map(navItem => ({
name: navItem.name,
canAccess: false,
canView: false,
canAdd: false,
canEdit: false,
canDelete: false,
})),
hakAksesMenu: buildMenuTemplate(navItemsStore.navItems),
};
selectedUserId.value = null;
selectedTipeUser.value = null;
fetchedBackendData.value = [];
};
const saveItem = () => {
// Check if a permission is mapped to a menu
const getMappingStatus = (pagename: string): boolean => {
if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) {
return false;
}
return editedItem.value.hakAksesMenu.some(menu =>
menu.name.toLowerCase() === pagename.toLowerCase() ||
menu.name.toLowerCase().includes(pagename.toLowerCase()) ||
pagename.toLowerCase().includes(menu.name.toLowerCase())
);
};
// Fill user data when user is selected by ID
const fillUserData = (userId: string | null) => {
if (!userId) {
editedItem.value.userId = '';
editedItem.value.namaLengkap = '';
editedItem.value.namaUser = '';
editedItem.value.tipeUser = '';
selectedTipeUser.value = null;
return;
}
const user = availableUsers.value.find(u => u.id === userId);
if (user) {
editedItem.value.userId = user.id;
editedItem.value.namaLengkap = user.namaLengkap || '';
editedItem.value.namaUser = user.namaUser || '';
editedItem.value.tipeUser = user.tipeUser || '';
selectedTipeUser.value = user.tipeUser || null;
// Auto-fill role and group if available
if (user.realmRoles && user.realmRoles.length > 0) {
editedItem.value.role = user.realmRoles[0];
}
if (user.groups && user.groups.length > 0) {
const groupPath = user.groups[0];
const parts = groupPath.split('/').filter(Boolean);
if (parts.length > 1) {
editedItem.value.group = parts[1];
} else if (parts.length === 1) {
editedItem.value.group = parts[0];
}
}
}
};
// Fill user data when tipe user is selected
const fillUserDataByTipeUser = (tipeUser: string | null) => {
if (!tipeUser) {
editedItem.value.tipeUser = '';
return;
}
editedItem.value.tipeUser = tipeUser;
// Find first user with this tipe user and fill their data
const user = availableUsers.value.find(u => u.tipeUser === tipeUser);
if (user) {
editedItem.value.userId = user.id;
editedItem.value.namaLengkap = user.namaLengkap || '';
editedItem.value.namaUser = user.namaUser || '';
// Auto-fill role and group if available
if (user.realmRoles && user.realmRoles.length > 0) {
editedItem.value.role = user.realmRoles[0];
}
if (user.groups && user.groups.length > 0) {
const groupPath = user.groups[0];
const parts = groupPath.split('/').filter(Boolean);
if (parts.length > 1) {
editedItem.value.group = parts[1];
} else if (parts.length === 1) {
editedItem.value.group = parts[0];
}
}
}
};
// Snackbar for notifications
const snackbar = ref({
show: false,
message: '',
color: 'success',
timeout: 3000,
});
// Fetch permissions from backend API
const fetchPermissionsFromBackend = async () => {
if (!editedItem.value.role || !editedItem.value.group) {
snackbar.value = {
show: true,
message: 'Role dan Group harus diisi terlebih dahulu!',
color: 'warning',
timeout: 3000,
};
return;
}
isFetchingPermissions.value = true;
try {
console.log('🔄 Fetching permissions from backend...');
console.log('📋 Request params:', {
roles: editedItem.value.role,
groups: editedItem.value.group,
});
const response = await $fetch<any>('/api/permission', {
query: {
roles: editedItem.value.role,
groups: editedItem.value.group,
},
});
console.log('📦 Full response:', response);
// Map backend permissions to our menu structure
// Backend response structure: { message, data: [...], meta: {...} }
if (response && (response as any).data && Array.isArray((response as any).data)) {
const backendPermissions = (response as any).data as BackendPermissionItem[];
console.log(`✅ Received ${backendPermissions.length} permissions from backend`);
console.log('📄 Backend permissions:', backendPermissions);
// Store fetched data for display
fetchedBackendData.value = backendPermissions;
const menuTemplate = buildMenuTemplate(navItemsStore.navItems);
console.log(`📋 Menu template has ${menuTemplate.length} items`);
// 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: BackendPermissionItem) =>
perm.pagename?.toLowerCase() === menu.name.toLowerCase() ||
perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) ||
menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '')
);
if (backendPerm) {
console.log(`✅ Matched menu "${menu.name}" with permission "${backendPerm.pagename}"`);
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,
};
} else {
console.log(`⚠️ No match found for menu "${menu.name}"`);
}
return menu;
});
editedItem.value.hakAksesMenu = mappedPermissions;
const matchedCount = mappedPermissions.filter(m =>
backendPermissions.some(p =>
p.pagename?.toLowerCase() === m.name.toLowerCase() ||
p.pagename?.toLowerCase().includes(m.name.toLowerCase()) ||
m.name.toLowerCase().includes(p.pagename?.toLowerCase() || '')
)
).length;
snackbar.value = {
show: true,
message: `Berhasil mengambil ${backendPermissions.length} permissions dari backend. ${matchedCount} menu berhasil di-mapping.`,
color: 'success',
timeout: 5000,
};
} else {
console.warn('⚠️ Response does not have expected structure:', response);
fetchedBackendData.value = [];
snackbar.value = {
show: true,
message: 'Response dari backend tidak memiliki struktur yang diharapkan',
color: 'warning',
timeout: 3000,
};
}
} catch (error: any) {
console.error('❌ Error fetching permissions from backend:', error);
snackbar.value = {
show: true,
message: `Gagal mengambil data dari backend: ${error.message || 'Unknown error'}`,
color: 'error',
timeout: 5000,
};
} finally {
isFetchingPermissions.value = false;
}
};
// Load available roles and groups from UserLogin data
const loadAvailableRolesAndGroups = async () => {
try {
const users = await $fetch('/api/users/list').catch(() => []);
const rolesSet = new Set<string>();
const groupsSet = new Set<string>();
// Store users for selection
availableUsers.value = (users as any[]).map((u: any) => ({
id: u.id,
namaLengkap: u.namaLengkap || '',
namaUser: u.namaUser || '',
tipeUser: u.tipeUser || '',
roles: u.roles || [],
realmRoles: u.realmRoles || [],
groups: u.groups || [],
}));
(users as any[]).forEach((u: any) => {
if (Array.isArray(u.roles)) {
u.roles.forEach((r: string) => rolesSet.add(r));
}
if (Array.isArray(u.realmRoles)) {
u.realmRoles.forEach((r: string) => rolesSet.add(r));
}
if (Array.isArray(u.groups)) {
u.groups.forEach((g: string) => {
// Extract group name from path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM")
const parts = g.split('/').filter(Boolean);
if (parts.length > 1) {
groupsSet.add(parts[1]); // Get second part as group name
} else if (parts.length === 1) {
groupsSet.add(parts[0]);
}
});
}
});
availableRoles.value = Array.from(rolesSet).sort();
availableGroups.value = Array.from(groupsSet).sort();
// Extract unique tipe users
const tipeUsersSet = new Set<string>();
(users as any[]).forEach((u: any) => {
if (u.tipeUser) {
tipeUsersSet.add(u.tipeUser);
}
});
availableTipeUsers.value = Array.from(tipeUsersSet).sort();
} catch (error) {
console.error('Error loading roles and groups:', error);
}
};
const saveItem = async () => {
// Validate required fields
if (!editedItem.value.role || !editedItem.value.group) {
snackbar.value = {
show: true,
message: 'Role dan Group wajib diisi!',
color: 'warning',
timeout: 3000,
};
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 ||
!editedItem.value.hakAksesMenu.some(m => m.canAccess || m.canView || m.canAdd || m.canEdit || m.canDelete);
if (needsFetch) {
snackbar.value = {
show: true,
message: 'Mengambil permissions dari backend...',
color: 'info',
timeout: 2000,
};
// Fetch permissions from backend before saving
await fetchPermissionsFromBackend();
// Wait a bit for the fetch to complete
await new Promise(resolve => setTimeout(resolve, 500));
}
// Ensure hakAksesMenu exists
if (!editedItem.value.hakAksesMenu || editedItem.value.hakAksesMenu.length === 0) {
editedItem.value.hakAksesMenu = buildMenuTemplate(navItemsStore.navItems);
}
// Create a clean copy of the item to save
const itemToSave: HakAksesData = {
id: editedItem.value.id || 0,
userId: editedItem.value.userId || '',
namaLengkap: editedItem.value.namaLengkap || '',
namaUser: editedItem.value.namaUser || '',
tipeUser: editedItem.value.tipeUser || '',
role: editedItem.value.role,
group: editedItem.value.group,
namaTipeUser: editedItem.value.namaTipeUser || editedItem.value.tipeUser || '',
hakAksesMenu: editedItem.value.hakAksesMenu || [],
};
console.log('💾 Saving item:', itemToSave);
console.log('📊 Current allHakAksesData length:', allHakAksesData.value.length);
if (editedIndex.value > -1) {
// Edit item
Object.assign(allHakAksesData.value[editedIndex.value], editedItem.value);
allHakAksesData.value[editedIndex.value] = { ...itemToSave };
console.log('✅ Item updated at index:', editedIndex.value);
snackbar.value = {
show: true,
message: 'Data hak akses berhasil diperbarui!',
color: 'success',
timeout: 3000,
};
} else {
// Add item with a new ID
editedItem.value.id = allHakAksesData.value.length + 1;
allHakAksesData.value.push(editedItem.value);
const newId = allHakAksesData.value.length > 0
? Math.max(...allHakAksesData.value.map(i => i.id)) + 1
: 1;
itemToSave.id = newId;
allHakAksesData.value.push({ ...itemToSave });
console.log('✅ New item added with ID:', newId);
console.log('📊 New allHakAksesData length:', allHakAksesData.value.length);
reindexData(); // Re-index to ensure sequential IDs
snackbar.value = {
show: true,
message: 'Data hak akses berhasil ditambahkan!',
color: 'success',
timeout: 3000,
};
}
// Force update localStorage
console.log('💾 Final allHakAksesData:', allHakAksesData.value);
cancelForm();
};
// Handle initial data load and ID re-indexing
onMounted(() => {
onMounted(async () => {
console.log('🚀 HakAkses page mounted');
console.log('📊 Initial allHakAksesData:', allHakAksesData.value);
console.log('📊 Initial allHakAksesData length:', allHakAksesData.value.length);
reindexData();
await loadAvailableRolesAndGroups();
console.log('✅ After load - allHakAksesData length:', allHakAksesData.value.length);
console.log('✅ After load - allHakAksesData:', allHakAksesData.value);
// Debug: Check localStorage directly
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('allHakAksesData');
console.log('💾 localStorage value:', stored);
if (stored) {
try {
const parsed = JSON.parse(stored);
console.log('📦 Parsed localStorage data:', parsed);
} catch (e) {
console.error('❌ Error parsing localStorage:', e);
}
}
}
});
</script>
+235 -46
View File
@@ -3,10 +3,10 @@
<!-- Display Authentication Info for context -->
<div class="mb-4 pa-3 bg-blue-grey-lighten-5 rounded-lg text-caption">
User Status:
<v-chip :color="isAuthenticated ? 'green' : 'red'" size="small" class="ml-2 mr-1">
{{ isAuthenticated ? 'Authenticated' : 'Unauthenticated' }}
<v-chip :color="currentUserData ? 'green' : 'red'" size="small" class="ml-2 mr-1">
{{ currentUserData ? 'Authenticated' : 'Unauthenticated' }}
</v-chip>
(Username: <strong>{{ user?.username || 'N/A' }}</strong>)
(Username: <strong>{{ currentUserData?.namaUser || currentUserData?.namaLengkap || 'N/A' }}</strong>)
</div>
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
@@ -70,33 +70,64 @@
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Keterangan</v-label>
<v-label class="font-weight-bold">Last Access</v-label>
<v-text-field
v-model="editedItem.keterangan"
placeholder="Masukkan Keterangan"
:value="formatLastLogin(editedItem.lastLogin)"
placeholder="Last Access Time"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Roles</v-label>
<v-col cols="12" md="4">
<v-label class="font-weight-bold">Realm Roles</v-label>
<v-select
v-model="editedItem.roles"
v-model="editedItem.realmRoles"
:items="availableRoles"
multiple
chips
placeholder="Pilih Roles Pengguna"
placeholder="Pilih Realm Roles"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold">Account Roles</v-label>
<v-select
v-model="editedItem.accountRoles"
:items="availableRoles"
multiple
chips
placeholder="Pilih Account Roles"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-label class="font-weight-bold">Resource Roles</v-label>
<v-select
v-model="editedItem.resourceRoles"
:items="availableRoles"
multiple
chips
placeholder="Pilih Resource Roles"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Groups</v-label>
<v-select
@@ -155,16 +186,18 @@
<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="success"
prepend-icon="mdi-plus"
<v-btn
color="white"
prepend-icon="mdi-refresh"
rounded
class="text-capitalize"
@click="showAddForm"
:disabled="isPending"
@click="refreshAllUsers"
:disabled="isPending || isSaving"
:loading="isSaving"
variant="outlined"
>
Tambah User
</v-btn> -->
Refresh Data
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
@@ -208,9 +241,9 @@
loading-text="Memuat data pengguna..."
class="rounded-lg elevation-0 custom-table"
>
<template v-slot:item.roles="{ item }">
<template v-slot:item.realmRoles="{ item }">
<v-chip
v-for="role in item.roles"
v-for="role in (item.realmRoles || [])"
:key="role"
color="blue-lighten-1"
size="small"
@@ -218,11 +251,38 @@
>
{{ role }}
</v-chip>
<span v-if="!item.realmRoles || item.realmRoles.length === 0" class="text-grey">-</span>
</template>
<template v-slot:item.accountRoles="{ item }">
<v-chip
v-for="role in (item.accountRoles || [])"
:key="role"
color="green-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ role }}
</v-chip>
<span v-if="!item.accountRoles || item.accountRoles.length === 0" class="text-grey">-</span>
</template>
<template v-slot:item.resourceRoles="{ item }">
<v-chip
v-for="role in (item.resourceRoles || [])"
:key="role"
color="orange-lighten-1"
size="small"
class="mr-1 mb-1"
>
{{ role }}
</v-chip>
<span v-if="!item.resourceRoles || item.resourceRoles.length === 0" class="text-grey">-</span>
</template>
<template v-slot:item.groups="{ item }">
<v-chip
v-for="group in item.groups"
v-for="group in (item.groups || [])"
:key="group"
color="purple-lighten-1"
size="small"
@@ -230,6 +290,13 @@
>
{{ group }}
</v-chip>
<span v-if="!item.groups || item.groups.length === 0" class="text-grey">-</span>
</template>
<template v-slot:item.lastLogin="{ item }">
<span class="text-body-2">
{{ formatLastLogin(item.lastLogin) }}
</span>
</template>
<template v-slot:item.actions="{ item }">
@@ -293,7 +360,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
// ----------------------------------------------------------------------
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
@@ -313,8 +380,11 @@ interface UserManagementItem {
namaLengkap: string;
namaUser: string; // Keycloak username
tipeUser: string; // Custom attribute mapping
keterangan: string;
roles: string[];
lastLogin: number | null; // Last access timestamp from Keycloak
roles: string[]; // Legacy: combined roles
realmRoles: string[]; // Roles from realm_access
accountRoles: string[]; // Roles from account resource
resourceRoles: string[]; // Roles from other resources (format: "resource:role")
groups: string[];
password?: string;
}
@@ -332,28 +402,85 @@ interface InitialData {
// 1. Get Auth State
const { user, isAuthenticated } = useAuth();
// 2. Get current user data from JWT token (like index.vue)
const currentUserData = ref<any>(null);
const currentUserRoles = ref<string[]>([]);
const currentUserGroups = ref<string[]>([]);
// 2. Use useAsyncData for Server-Side Data Fetching
// Function to get current user from JWT token
const fetchCurrentUser = async () => {
try {
const userData = await $fetch('/api/users/current');
currentUserData.value = userData;
currentUserRoles.value = userData.roles || [];
currentUserGroups.value = userData.groups || [];
// Extract unique roles and groups from all users for dropdown options
// We'll collect these from the users list
return userData;
} catch (error: any) {
console.error('Failed to fetch current user:', error);
return null;
}
};
// 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');
} catch (error: any) {
console.error('Failed to sync user:', error);
// Don't show error to user, just log it
}
};
// 4. Use useAsyncData for Server-Side Data Fetching
const { data: initialData, pending: isPending, error: fetchError, refresh } = await useAsyncData<InitialData>('user-management-data', async () => {
// Note: $fetch is used here and will work on both server and client
const [users, roles, groups] = await Promise.all([
// API endpoint to fetch all users (accessible by admin/manager)
$fetch('/api/users/list'),
// API endpoint to fetch available Keycloak roles
$fetch('/api/keycloak/roles'),
// API endpoint to fetch available Keycloak groups
$fetch('/api/keycloak/groups'),
]);
// Ensure data is formatted correctly before returning
return {
users: (users as UserManagementItem[]).map((u: any) => ({ ...u, id: u.id || 'N/A' })),
roles: roles as string[],
groups: groups as string[],
};
try {
// Fetch all users
const users = await $fetch('/api/users/list').catch(() => []);
// Extract unique roles and groups from all users for dropdown options
const allRolesSet = new Set<string>();
const allGroupsSet = new Set<string>();
(users as any[]).forEach((u: any) => {
if (Array.isArray(u.roles)) {
u.roles.forEach((r: string) => allRolesSet.add(r));
}
if (Array.isArray(u.groups)) {
u.groups.forEach((g: string) => allGroupsSet.add(g));
}
});
// Also get current user's roles/groups if available
try {
const currentUser = await fetchCurrentUser();
if (currentUser) {
currentUser.roles?.forEach((r: string) => allRolesSet.add(r));
currentUser.groups?.forEach((g: string) => allGroupsSet.add(g));
}
} catch (e) {
// Ignore if current user fetch fails
}
return {
users: (users as UserManagementItem[]).map((u: any) => ({ ...u, id: u.id || 'N/A' })),
roles: Array.from(allRolesSet).sort(),
groups: Array.from(allGroupsSet).sort(),
};
} catch (error: any) {
console.error('Error fetching user management data:', error);
return {
users: [],
roles: [],
groups: [],
};
}
}, {
// Refresh interval for live data (optional)
// server: false // Uncomment if you want this block to run only on client side
server: false // Run on client side to ensure session is available
});
// State synchronization: Initialize local refs from useAsyncData result
@@ -371,6 +498,43 @@ 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();
});
// Function to refresh all users data (re-sync from tokens)
const refreshAllUsers = async () => {
try {
isSaving.value = true;
// Call sync endpoint to update current user
await syncCurrentUser();
// Refresh the list
await refresh();
snackbar.value = {
show: true,
message: 'Data user berhasil di-refresh dari Keycloak!',
color: 'success',
timeout: 3000
};
} catch (error: any) {
console.error('Error refreshing users:', error);
snackbar.value = {
show: true,
message: 'Gagal refresh data user',
color: 'error',
timeout: 3000
};
} finally {
isSaving.value = false;
}
};
// --- LOCAL STATE ---
@@ -380,9 +544,11 @@ const headers = ref([
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
{ title: 'Nama User', key: 'namaUser', sortable: true },
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
{ title: 'Roles', key: 'roles', sortable: false },
{ title: 'Realm Roles', key: 'realmRoles', sortable: false },
{ title: 'Account Roles', key: 'accountRoles', sortable: false },
{ title: 'Resource Roles', key: 'resourceRoles', sortable: false },
{ title: 'Groups', key: 'groups', sortable: false },
{ title: 'Keterangan', key: 'keterangan', sortable: true },
{ title: 'Last Access', key: 'lastLogin', sortable: true },
{ title: 'Aksi', key: 'actions', sortable: false },
]);
@@ -417,9 +583,12 @@ const emptyItem: UserManagementItem = {
namaLengkap: '',
namaUser: '',
tipeUser: '',
keterangan: '',
lastLogin: null,
password: '',
roles: [],
realmRoles: [],
accountRoles: [],
resourceRoles: [],
groups: []
};
const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
@@ -427,6 +596,24 @@ const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
// --- COMPUTED PROPERTIES ---
// Format last access time (data from Keycloak)
const formatLastLogin = (timestamp: number | null): string => {
if (!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');
};
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
@@ -497,8 +684,10 @@ const saveItem = async () => {
namaLengkap: editedItem.value.namaLengkap,
username: editedItem.value.namaUser,
tipeUser: editedItem.value.tipeUser,
keterangan: editedItem.value.keterangan,
roles: editedItem.value.roles,
realmRoles: editedItem.value.realmRoles || [],
accountRoles: editedItem.value.accountRoles || [],
resourceRoles: editedItem.value.resourceRoles || [],
groups: editedItem.value.groups,
// Only include password if it was set
...(editedItem.value.password && { password: editedItem.value.password })
+3 -3
View File
@@ -142,9 +142,9 @@ const qrCodeData = computed(() => {
});
const qrSize = computed(() => {
if (display.xs.value) return 200;
if (display.sm.value) return 240;
return 280;
if (display.xs.value) return 120;
if (display.sm.value) return 200;
return 220;
});
// Methods
+22
View File
@@ -0,0 +1,22 @@
// plugins/keyboard-shortcuts.client.ts
export default defineNuxtPlugin(() => {
const router = useRouter();
const handleKeyPress = (event: KeyboardEvent) => {
// Alt + Shift + H
if (event.altKey && event.shiftKey && event.key.toLowerCase() === 'h') {
event.preventDefault();
console.log('🎯 Alt+Shift+H detected - setting bypass flag');
// Set a temporary flag to bypass middleware
sessionStorage.setItem('bypassRootRedirect', 'true');
// Navigate to root
router.push('/');
}
};
window.addEventListener('keydown', handleKeyPress);
console.log('✅ Keyboard shortcuts loaded - Press Alt+Shift+H to access root');
});
+12 -1
View File
@@ -1,7 +1,7 @@
// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX
// Add this at the top of the file (after imports)
const SESSION_DURATION = 24 * 60 * 60; // 7 days in seconds (customize as needed)
const SESSION_DURATION = 1 * 60 * 60; // 7 days in seconds (customize as needed)
// Or use one of these alternatives:
// const SESSION_DURATION = 24 * 60 * 60; // 1 day
// const SESSION_DURATION = 30 * 24 * 60 * 60; // 30 days
@@ -124,6 +124,17 @@ export default defineEventHandler(async (event) => {
console.log('✅ Session cookie created successfully');
// Auto-sync user data to database (first time login check)
// Pass session createdAt as loginTime to sync function
try {
const { syncUserFromTokens } = await import('~/server/utils/userSync');
const result = syncUserFromTokens(tokens.id_token, tokens.access_token, sessionData.createdAt);
console.log(`✅ User auto-sync on login: ${result.action} - ${result.message}`);
} catch (syncError: any) {
// Don't fail the login if sync fails, just log it
console.error('⚠️ Failed to auto-sync user on login:', syncError);
}
const testCookie = getCookie(event, 'user_session');
console.log('🧪 Cookie test - can read back in this handler (Expected False):', !!testCookie);
+81
View File
@@ -0,0 +1,81 @@
// server/api/permission.get.ts
// Proxy endpoint to fetch permissions from backend API
export default defineEventHandler(async (event) => {
console.log("🔐 Permission endpoint called");
const query = getQuery(event);
const roles = query.roles as string | string[];
const groups = query.groups as string | string[];
if (!roles && !groups) {
throw createError({
statusCode: 400,
statusMessage: "roles or groups parameter is required",
});
}
// Convert to arrays and handle single values
const rolesArray = Array.isArray(roles) ? roles : roles ? [roles] : [];
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] || '';
// Build query parameters
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()}`;
try {
console.log(`📡 Fetching permissions from: ${backendUrl}`);
console.log(`📋 Query params - roles: ${primaryRole}, groups: ${primaryGroup}`);
const response = await $fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
console.log("✅ Permission data fetched successfully");
console.log("📦 Response structure:", {
hasMessage: !!(response as any).message,
hasData: !!(response as any).data,
dataLength: Array.isArray((response as any).data) ? (response as any).data.length : 0,
hasMeta: !!(response as any).meta,
});
// Log first permission item for debugging
if ((response as any).data && Array.isArray((response as any).data) && (response as any).data.length > 0) {
console.log("📄 Sample permission item:", (response as any).data[0]);
}
// Return the response as-is (it should have { message, data, meta } structure)
return response;
} catch (error: any) {
console.error("❌ Error fetching permissions:", error);
console.error("❌ Error details:", {
message: error.message,
status: error.status || error.statusCode,
statusText: error.statusText || error.statusMessage,
data: error.data,
});
// Return empty permissions structure if API fails
return {
message: error.message || "Failed to fetch permissions",
data: [],
meta: {
count: 0,
total: 0
}
};
}
});
+63
View File
@@ -0,0 +1,63 @@
// server/api/users/[id].delete.ts
// Delete user
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync } from 'fs';
const getDbPath = () => {
const dbDir = join(process.cwd(), 'data');
return join(dbDir, 'users.db');
};
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id');
console.log(`🗑️ Delete user endpoint called for ID: ${userId}`);
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: "User ID is required",
});
}
try {
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
throw createError({
statusCode: 404,
statusMessage: "Database not found",
});
}
const db = new Database(dbPath);
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
if (!existingUser) {
db.close();
throw createError({
statusCode: 404,
statusMessage: "User not found",
});
}
// Delete user
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
db.close();
console.log(`✅ User deleted: ${userId}`);
return { success: true, message: 'User deleted successfully' };
} catch (error: any) {
console.error("❌ Error deleting user:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Failed to delete user",
});
}
});
+111
View File
@@ -0,0 +1,111 @@
// server/api/users/[id].patch.ts
// Update user data
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync } from 'fs';
const getDbPath = () => {
const dbDir = join(process.cwd(), 'data');
return join(dbDir, 'users.db');
};
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id');
const body = await readBody(event);
console.log(`🔄 Update user endpoint called for ID: ${userId}`);
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: "User ID is required",
});
}
try {
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
throw createError({
statusCode: 404,
statusMessage: "Database not found",
});
}
const db = new Database(dbPath);
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
if (!existingUser) {
db.close();
throw createError({
statusCode: 404,
statusMessage: "User not found",
});
}
// Prepare update fields
const updateFields: string[] = [];
const updateValues: any[] = [];
if (body.namaLengkap !== undefined) {
updateFields.push('namaLengkap = ?');
updateValues.push(body.namaLengkap);
}
if (body.tipeUser !== undefined) {
updateFields.push('tipeUser = ?');
updateValues.push(body.tipeUser);
}
if (body.lastLogin !== undefined) {
updateFields.push('lastLogin = ?');
updateValues.push(body.lastLogin);
}
if (body.roles !== undefined) {
updateFields.push('roles = ?');
updateValues.push(JSON.stringify(Array.isArray(body.roles) ? body.roles : []));
}
if (body.realmRoles !== undefined) {
updateFields.push('realmRoles = ?');
updateValues.push(JSON.stringify(Array.isArray(body.realmRoles) ? body.realmRoles : []));
}
if (body.accountRoles !== undefined) {
updateFields.push('accountRoles = ?');
updateValues.push(JSON.stringify(Array.isArray(body.accountRoles) ? body.accountRoles : []));
}
if (body.resourceRoles !== undefined) {
updateFields.push('resourceRoles = ?');
updateValues.push(JSON.stringify(Array.isArray(body.resourceRoles) ? body.resourceRoles : []));
}
if (body.groups !== undefined) {
updateFields.push('groups = ?');
updateValues.push(JSON.stringify(Array.isArray(body.groups) ? body.groups : []));
}
if (updateFields.length === 0) {
db.close();
return { success: true, message: 'No fields to update' };
}
// Add updatedAt
updateFields.push('updatedAt = strftime(\'%s\', \'now\')');
updateValues.push(userId);
// Execute update
const sql = `UPDATE users SET ${updateFields.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...updateValues);
db.close();
console.log(`✅ User updated: ${userId}`);
return { success: true, message: 'User updated successfully' };
} catch (error: any) {
console.error("❌ Error updating user:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Failed to update user",
});
}
});
+124
View File
@@ -0,0 +1,124 @@
// server/api/users/create.post.ts
// Create new user (manual creation)
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
const getDbPath = () => {
const dbDir = join(process.cwd(), 'data');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'users.db');
};
const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
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: Add new columns if they don't exist
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);
}
}
return db;
};
export default defineEventHandler(async (event) => {
const body = await readBody(event);
console.log(" Create user endpoint called");
if (!body.namaLengkap || !body.username) {
throw createError({
statusCode: 400,
statusMessage: "namaLengkap and username are required",
});
}
try {
const db = initDb();
// Generate ID if not provided (for manual creation)
const userId = body.id || `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Check if username already exists
const existingUser = db.prepare('SELECT * FROM users WHERE namaUser = ?').get(body.username) as any;
if (existingUser) {
db.close();
throw createError({
statusCode: 409,
statusMessage: "Username already exists",
});
}
// Insert new user
db.prepare(`
INSERT INTO users (
id, namaLengkap, namaUser, email, tipeUser, lastLogin,
roles, realmRoles, accountRoles, resourceRoles, groups, given_name, family_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
body.namaLengkap,
body.username,
body.email || null,
body.tipeUser || '',
body.lastLogin || Math.floor(Date.now() / 1000),
JSON.stringify(Array.isArray(body.roles) ? body.roles : []),
JSON.stringify(Array.isArray(body.realmRoles) ? body.realmRoles : []),
JSON.stringify(Array.isArray(body.accountRoles) ? body.accountRoles : []),
JSON.stringify(Array.isArray(body.resourceRoles) ? body.resourceRoles : []),
JSON.stringify(Array.isArray(body.groups) ? body.groups : []),
body.given_name || null,
body.family_name || null
);
db.close();
console.log(`✅ User created: ${userId}`);
return {
success: true,
message: 'User created successfully',
id: userId
};
} catch (error: any) {
console.error("❌ Error creating user:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Failed to create user",
});
}
});
+133
View File
@@ -0,0 +1,133 @@
// server/api/users/current.get.ts
// Get current logged-in user data from JWT token
// Helper function 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) {
console.error("❌ Failed to decode token payload:", e);
return null;
}
};
export default defineEventHandler(async (event) => {
console.log("🔍 Current user endpoint called");
const sessionCookie = getCookie(event, "user_session");
if (!sessionCookie) {
throw createError({
statusCode: 401,
statusMessage: "No session cookie found",
});
}
try {
const session = JSON.parse(sessionCookie);
const isExpired = Date.now() > session.expiresAt;
if (isExpired) {
deleteCookie(event, "user_session");
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
// Decode tokens to get full user data
const idTokenPayload = decodeTokenPayload(session.idToken);
const accessTokenPayload = decodeTokenPayload(session.accessToken);
// Extract roles from different sources
const realmRoles = accessTokenPayload?.realm_access?.roles ||
idTokenPayload?.realm_access?.roles ||
[];
const accountRoles = accessTokenPayload?.resource_access?.account?.roles ||
idTokenPayload?.resource_access?.account?.roles ||
[];
// Extract resource roles (from all resources in resource_access)
const resourceRoles: string[] = [];
if (accessTokenPayload?.resource_access) {
Object.keys(accessTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = accessTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
if (idTokenPayload?.resource_access) {
Object.keys(idTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = idTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
// Legacy: Combined roles (for backward compatibility)
const roles = [...realmRoles, ...accountRoles, ...resourceRoles];
// Keycloak uses 'groups_join' in access token, not 'groups'
const groups = accessTokenPayload?.groups_join ||
accessTokenPayload?.groups ||
idTokenPayload?.groups_join ||
idTokenPayload?.groups ||
[];
// Determine tipeUser from groups or roles if possible
// You can customize this mapping based on your business logic
let tipeUser = '';
if (Array.isArray(groups) && groups.length > 0) {
// Extract tipeUser from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin")
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 part of path
}
}
}
// Build user data object
const userData = {
id: idTokenPayload?.sub || session.user?.id,
namaLengkap: idTokenPayload?.name ||
session.user?.name ||
`${idTokenPayload?.given_name || ''} ${idTokenPayload?.family_name || ''}`.trim() ||
idTokenPayload?.preferred_username,
namaUser: idTokenPayload?.preferred_username ||
session.user?.preferred_username ||
idTokenPayload?.email?.split('@')[0],
email: idTokenPayload?.email || session.user?.email,
given_name: idTokenPayload?.given_name,
family_name: idTokenPayload?.family_name,
roles: Array.isArray(roles) ? roles : [],
realmRoles: Array.isArray(realmRoles) ? realmRoles : [],
accountRoles: Array.isArray(accountRoles) ? accountRoles : [],
resourceRoles: Array.isArray(resourceRoles) ? resourceRoles : [],
groups: Array.isArray(groups) ? groups : [],
tipeUser: tipeUser, // Extracted from groups or empty
lastLogin: null, // Will be set on sync
// Include full token payloads for reference
idTokenPayload,
accessTokenPayload,
};
console.log("✅ Current user data extracted from JWT");
return userData;
} catch (parseError: any) {
console.error("❌ Failed to parse session or extract user data:", parseError);
throw createError({
statusCode: 401,
statusMessage: "Invalid session data",
});
}
});
+89
View File
@@ -0,0 +1,89 @@
// server/api/users/last-access.get.ts
// Get last access time from Keycloak Admin API for a specific user using access token from session
export default defineEventHandler(async (event) => {
console.log("🔍 Last access endpoint called");
const query = getQuery(event);
const userId = query.userId as string;
if (!userId) {
throw createError({
statusCode: 400,
statusMessage: "userId is required",
});
}
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");
}
if (!accessToken) {
return { lastAccess: null };
}
// Extract realm from issuer (e.g., "http://keycloak:8080/realms/sandbox" -> "sandbox")
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
// Get user sessions from Keycloak Admin API using access token
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) {
// If user has no sessions or no permission, return null
if (sessionsResponse.status === 404 || sessionsResponse.status === 403) {
console.log(`️ No sessions found or no permission for user ${userId}`);
return { lastAccess: null };
}
return { lastAccess: null };
}
const sessions = await sessionsResponse.json() as any[];
if (!sessions || sessions.length === 0) {
console.log(`️ No active sessions for user ${userId}`);
return { lastAccess: null };
}
// Find the most recent session (highest lastAccess timestamp)
let lastAccessTimestamp = 0;
sessions.forEach(session => {
if (session.lastAccess && session.lastAccess > lastAccessTimestamp) {
lastAccessTimestamp = session.lastAccess;
}
});
// Convert from milliseconds to seconds (Unix timestamp)
const lastAccess = lastAccessTimestamp > 0 ? Math.floor(lastAccessTimestamp / 1000) : null;
console.log(`✅ Last access for user ${userId}: ${lastAccess ? new Date(lastAccess * 1000).toISOString() : 'Never'}`);
return { lastAccess };
} catch (error: any) {
console.error("❌ Error fetching last access from Keycloak:", error);
// Return null instead of throwing error to allow graceful degradation
return { lastAccess: null };
}
});
+166
View File
@@ -0,0 +1,166 @@
// server/api/users/list.get.ts
// Get all users from database and enrich with last access from Keycloak
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 using access token from session
const getLastAccessFromKeycloak = async (userId: string, accessToken: string, config: any): Promise<number | null> => {
try {
if (!accessToken) {
return null;
}
// Extract realm from issuer
const issuerUrl = new URL(config.keycloakIssuer);
const realm = issuerUrl.pathname.split('/').filter(Boolean).pop() || 'master';
// Get user sessions from Keycloak Admin API using access token
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) {
if (sessionsResponse.status === 404 || sessionsResponse.status === 403) {
// User has no sessions or no permission
return null;
}
return null;
}
const sessions = await sessionsResponse.json() as any[];
if (!sessions || sessions.length === 0) {
return null;
}
// Find the most recent session (highest lastAccess timestamp)
let lastAccessTimestamp = 0;
sessions.forEach(session => {
if (session.lastAccess && session.lastAccess > lastAccessTimestamp) {
lastAccessTimestamp = session.lastAccess;
}
});
// Convert from milliseconds to seconds (Unix timestamp)
return lastAccessTimestamp > 0 ? Math.floor(lastAccessTimestamp / 1000) : null;
} catch (error: any) {
console.warn(`⚠️ Error fetching last access for user ${userId}:`, error.message);
return null;
}
};
export default defineEventHandler(async (event) => {
console.log("📋 Users list endpoint called");
try {
const config = useRuntimeConfig();
const dbPath = getDbPath();
// Check if database exists
if (!existsSync(dbPath)) {
console.log("️ Database not found, returning empty array");
return [];
}
// Try to 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) {
// No session available, will skip Keycloak fetch
console.log("️ No valid session found, will use database values for last access");
}
const db = new Database(dbPath);
// 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;
// Only fetch from Keycloak if we have a valid user ID, access token, and config
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
if (keycloakLastAccess) {
lastLogin = keycloakLastAccess;
}
} catch (error) {
// Silently fail and use database value
console.warn(`⚠️ Could not fetch last access for user ${user.id}, using database value`);
}
}
return {
id: user.id,
namaLengkap: user.namaLengkap,
namaUser: user.namaUser,
email: user.email,
tipeUser: user.tipeUser || '',
lastLogin: lastLogin,
roles: JSON.parse(user.roles || '[]'),
realmRoles: JSON.parse(user.realmRoles || '[]'),
accountRoles: JSON.parse(user.accountRoles || '[]'),
resourceRoles: JSON.parse(user.resourceRoles || '[]'),
groups: JSON.parse(user.groups || '[]'),
given_name: user.given_name,
family_name: user.family_name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}));
db.close();
console.log(`✅ Retrieved ${formattedUsers.length} users with last access data`);
return formattedUsers;
} catch (error: any) {
console.error("❌ Error fetching users:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to fetch users",
});
}
});
+44
View File
@@ -0,0 +1,44 @@
// server/api/users/sync.post.ts
// Auto-save/update user data when they first login
// This endpoint will be called automatically when user logs in
export default defineEventHandler(async (event) => {
console.log("🔄 User sync endpoint called");
const sessionCookie = getCookie(event, "user_session");
if (!sessionCookie) {
throw createError({
statusCode: 401,
statusMessage: "No session cookie found",
});
}
try {
const session = JSON.parse(sessionCookie);
const isExpired = Date.now() > session.expiresAt;
if (isExpired) {
deleteCookie(event, "user_session");
throw createError({
statusCode: 401,
statusMessage: "Session expired",
});
}
// Use the shared sync utility
// Use session createdAt as loginTime, or current time if not available
const { syncUserFromTokens } = await import('~/server/utils/userSync');
const loginTime = session.createdAt || Date.now();
const result = syncUserFromTokens(session.idToken, session.accessToken, loginTime);
return result;
} catch (error: any) {
console.error("❌ Error syncing user:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to sync user data",
});
}
});
+299
View File
@@ -0,0 +1,299 @@
// server/utils/userSync.ts
// Shared utility for syncing user data from JWT tokens to 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');
};
// Initialize database and create table if not exists
const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
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: Add new columns if they don't exist (for existing databases)
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');
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');
}
} catch (e: any) {
console.warn('Migration check note:', e.message);
}
return db;
};
// Helper function 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) {
console.error("❌ Failed to decode token payload:", e);
return null;
}
};
/**
* Sync user data from JWT tokens to database
* @param idToken - The ID token from Keycloak
* @param accessToken - The access token from Keycloak
* @param loginTime - Optional login timestamp (from session createdAt). If not provided, uses current time
* @returns Object with success status and action taken
*/
export const syncUserFromTokens = (
idToken: string,
accessToken: string,
loginTime?: number
): { success: boolean; action: 'created' | 'updated' | 'unchanged'; message: string } => {
try {
// Decode tokens
const idTokenPayload = decodeTokenPayload(idToken);
const accessTokenPayload = decodeTokenPayload(accessToken);
if (!idTokenPayload) {
throw new Error('Invalid ID token');
}
// Extract user data
const userId = idTokenPayload.sub;
const namaLengkap = idTokenPayload.name ||
`${idTokenPayload.given_name || ''} ${idTokenPayload.family_name || ''}`.trim() ||
idTokenPayload.preferred_username;
const namaUser = idTokenPayload.preferred_username ||
idTokenPayload.email?.split('@')[0];
const email = idTokenPayload.email;
if (!userId || !namaUser) {
throw new Error('Missing required user data (id or username)');
}
// Extract roles from different sources
const realmRoles = accessTokenPayload?.realm_access?.roles ||
idTokenPayload?.realm_access?.roles ||
[];
const accountRoles = accessTokenPayload?.resource_access?.account?.roles ||
idTokenPayload?.resource_access?.account?.roles ||
[];
// Extract resource roles (from all resources in resource_access)
const resourceRoles: string[] = [];
if (accessTokenPayload?.resource_access) {
Object.keys(accessTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = accessTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
if (idTokenPayload?.resource_access) {
Object.keys(idTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = idTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
// Legacy: Combined roles (for backward compatibility)
const roles = [...realmRoles, ...accountRoles, ...resourceRoles];
// Keycloak uses 'groups_join' in access token, not 'groups'
const groups = accessTokenPayload?.groups_join ||
accessTokenPayload?.groups ||
idTokenPayload?.groups_join ||
idTokenPayload?.groups ||
[];
// Determine tipeUser from groups or roles if possible
// Extract from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin")
let tipeUser = '';
if (Array.isArray(groups) && groups.length > 0) {
// Get the last group path and extract the last segment
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 part of path
}
}
}
// If no tipeUser from groups, you can also check roles or leave empty for manual entry
// Use provided loginTime or current time (convert to seconds if in milliseconds)
const lastLoginTimestamp = loginTime
? (loginTime > 10000000000 ? Math.floor(loginTime / 1000) : loginTime) // Convert ms to seconds if needed
: Math.floor(Date.now() / 1000);
// Initialize database
const db = initDb();
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
const rolesJson = JSON.stringify(Array.isArray(roles) ? roles : []);
const realmRolesJson = JSON.stringify(Array.isArray(realmRoles) ? realmRoles : []);
const accountRolesJson = JSON.stringify(Array.isArray(accountRoles) ? accountRoles : []);
const resourceRolesJson = JSON.stringify(Array.isArray(resourceRoles) ? resourceRoles : []);
const groupsJson = JSON.stringify(Array.isArray(groups) ? groups : []);
if (existingUser) {
// User exists - check if data needs updating
// Note: tipeUser is only updated if it's empty in database (to preserve manual edits)
const needsUpdate =
existingUser.namaLengkap !== namaLengkap ||
existingUser.namaUser !== namaUser ||
existingUser.email !== email ||
existingUser.roles !== rolesJson ||
existingUser.realmRoles !== realmRolesJson ||
existingUser.accountRoles !== accountRolesJson ||
existingUser.resourceRoles !== resourceRolesJson ||
existingUser.groups !== groupsJson ||
existingUser.given_name !== (idTokenPayload.given_name || null) ||
existingUser.family_name !== (idTokenPayload.family_name || null) ||
(existingUser.tipeUser === '' && tipeUser !== ''); // Only update if empty
if (needsUpdate) {
// Update user data
// Only update tipeUser if it's currently empty (preserve manual edits)
const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser;
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,
idTokenPayload.given_name || null,
idTokenPayload.family_name || null,
updateTipeUser,
lastLoginTimestamp, // Update lastLogin timestamp from session
userId
);
console.log("✅ User data updated:", userId);
db.close();
return {
success: true,
action: 'updated',
message: 'User data updated successfully'
};
} else {
console.log("️ User data unchanged:", userId);
db.close();
return {
success: true,
action: 'unchanged',
message: 'User data is up to date'
};
}
} else {
// New user - insert
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,
idTokenPayload.given_name || null,
idTokenPayload.family_name || null,
tipeUser, // tipeUser - extracted from groups or empty
lastLoginTimestamp // lastLogin - from session createdAt
);
console.log("✅ New user saved:", userId);
db.close();
return {
success: true,
action: 'created',
message: 'New user saved successfully'
};
}
} catch (error: any) {
console.error("❌ Error syncing user:", error);
throw error;
}
};