update user login baru dan hakakses
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Database files
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user