1292 lines
42 KiB
Vue
1292 lines
42 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="page-header">
|
|
<div class="header-content">
|
|
<div class="header-left">
|
|
<div class="header-icon">
|
|
<v-icon size="28" color="white">mdi-account-group</v-icon>
|
|
</div>
|
|
<div class="header-text">
|
|
<h2 class="page-title">User Login Management</h2>
|
|
<p class="page-subtitle">{{ new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) }} - Manajemen Pengguna</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="user-login-page pa-4">
|
|
<!-- Action Bar -->
|
|
<div class="action-bar mb-4">
|
|
<div class="action-bar-left">
|
|
<v-chip color="primary" variant="tonal" class="stat-chip mr-2">
|
|
<v-icon start size="16">mdi-account</v-icon>
|
|
{{ filteredUsers.length }} Pengguna
|
|
</v-chip>
|
|
<v-chip
|
|
v-if="autoRefreshInterval"
|
|
color="success"
|
|
variant="tonal"
|
|
class="stat-chip mr-2"
|
|
>
|
|
<v-icon start size="16">mdi-sync</v-icon>
|
|
Auto-refresh aktif
|
|
</v-chip>
|
|
</div>
|
|
<v-btn
|
|
color="primary"
|
|
@click="refreshAllUsers"
|
|
elevation="0"
|
|
class="action-btn"
|
|
:disabled="isPending || isSaving"
|
|
:loading="isSaving"
|
|
>
|
|
<v-icon left size="20">mdi-refresh</v-icon>
|
|
Sync dari Keycloak
|
|
</v-btn>
|
|
</div>
|
|
|
|
<v-alert
|
|
v-if="fetchError"
|
|
type="error"
|
|
variant="tonal"
|
|
class="mb-4"
|
|
icon="mdi-alert-circle-outline"
|
|
>
|
|
Failed to load initial data: {{ fetchError.message }}. Check your API connections.
|
|
</v-alert>
|
|
|
|
<!-- Form Dialog Popup -->
|
|
<v-dialog v-model="showForm" max-width="950px" :persistent="!readOnly" scrollable>
|
|
<v-card class="rounded-lg overflow-hidden elevation-8">
|
|
<v-card-title
|
|
class="text-h5 font-weight-bold pa-5 text-white dialog-header"
|
|
:class="readOnly ? 'bg-blue-darken-3' : isEditMode ? 'bg-orange-darken-2' : 'bg-green-darken-2'"
|
|
>
|
|
<v-avatar
|
|
color="primary-darken-1"
|
|
size="48"
|
|
class="mr-3 elevation-2"
|
|
>
|
|
<v-icon :icon="readOnly ? 'mdi-eye' : isEditMode ? 'mdi-pencil' : 'mdi-account-plus'" color="white" size="24"></v-icon>
|
|
</v-avatar>
|
|
<div>
|
|
<div class="text-h5 font-weight-bold">{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}</div>
|
|
<div class="text-caption text-white text-opacity-75 mt-1">
|
|
{{ readOnly ? 'Lihat informasi detail pengguna' : isEditMode ? 'Ubah data pengguna yang ada' : 'Tambahkan pengguna baru ke sistem' }}
|
|
</div>
|
|
</div>
|
|
</v-card-title>
|
|
<v-card-text class="pa-6">
|
|
<!-- Basic Information Section -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
|
|
<v-icon icon="mdi-information" size="small" class="mr-2 text-primary"></v-icon>
|
|
Informasi Dasar
|
|
</div>
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="editedItem.namaLengkap"
|
|
label="Nama Lengkap"
|
|
placeholder="Masukkan Nama Lengkap"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="editedItem.namaUser"
|
|
label="Nama User"
|
|
placeholder="Masukkan Nama User"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account-circle-outline"
|
|
:readonly="readOnly || isEditMode"
|
|
:hint="isEditMode ? 'Username tidak dapat diubah' : ''"
|
|
persistent-hint
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-text-field>
|
|
</v-col>
|
|
</v-row>
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="editedItem.tipeUser"
|
|
label="Tipe User"
|
|
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
|
|
placeholder="Pilih Tipe User"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account-tie-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
:value="formatLastLogin(editedItem.lastLogin)"
|
|
label="Last Access"
|
|
placeholder="Last Access Time"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-clock-time-four-outline"
|
|
readonly
|
|
disabled
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-text-field>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<v-divider class="my-4"></v-divider>
|
|
<!-- Roles & Permissions Section -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
|
|
<v-icon icon="mdi-shield-account" size="small" class="mr-2 text-primary"></v-icon>
|
|
Roles & Permissions
|
|
</div>
|
|
<v-row>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="editedItem.realmRoles"
|
|
label="Realm Roles"
|
|
:items="availableRoles"
|
|
multiple
|
|
chips
|
|
placeholder="Pilih Realm Roles"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-shield-account-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="editedItem.accountRoles"
|
|
label="Account Roles"
|
|
:items="availableRoles"
|
|
multiple
|
|
chips
|
|
placeholder="Pilih Account Roles"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account-key-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="editedItem.resourceRoles"
|
|
label="Resource Roles"
|
|
:items="availableRoles"
|
|
multiple
|
|
chips
|
|
placeholder="Pilih Resource Roles"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-folder-key-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-select>
|
|
</v-col>
|
|
</v-row>
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="editedItem.groups"
|
|
label="Groups"
|
|
:items="availableGroups"
|
|
multiple
|
|
chips
|
|
placeholder="Pilih Groups Pengguna"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account-group-outline"
|
|
:readonly="readOnly"
|
|
hide-details="auto"
|
|
class="mb-3"
|
|
></v-select>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<v-divider v-if="!readOnly" class="my-4"></v-divider>
|
|
|
|
<!-- Security Section -->
|
|
<div v-if="!readOnly" class="mb-2">
|
|
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
|
|
<v-icon icon="mdi-lock" size="small" class="mr-2 text-primary"></v-icon>
|
|
Keamanan
|
|
</div>
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-text-field
|
|
v-model="editedItem.password"
|
|
:label="isEditMode ? 'Ganti Password (Kosongkan jika tidak diubah)' : 'Password'"
|
|
placeholder="Masukkan Password"
|
|
type="password"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-lock-outline"
|
|
hide-details="auto"
|
|
disabled
|
|
class="mb-3"
|
|
></v-text-field>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
</v-card-text>
|
|
<v-card-actions class="d-flex justify-end pa-5 bg-grey-lighten-4">
|
|
<v-btn
|
|
color="grey-darken-1"
|
|
variant="flat"
|
|
class="text-capitalize mr-3 px-6"
|
|
rounded="0"
|
|
prepend-icon="mdi-close"
|
|
@click="cancelForm"
|
|
:disabled="isSaving"
|
|
size="default"
|
|
>
|
|
{{ readOnly ? 'Tutup' : 'Batal' }}
|
|
</v-btn>
|
|
<v-btn
|
|
v-if="!readOnly"
|
|
:color="isEditMode ? 'orange-darken-2' : 'green-darken-2'"
|
|
variant="flat"
|
|
class="text-capitalize px-6"
|
|
rounded="0"
|
|
:prepend-icon="isEditMode ? 'mdi-content-save' : 'mdi-check'"
|
|
@click="saveItem"
|
|
:loading="isSaving"
|
|
:disabled="isSaving"
|
|
size="default"
|
|
>
|
|
{{ isEditMode ? 'Simpan Perubahan' : 'Simpan' }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<div>
|
|
<v-card class="mb-4">
|
|
<!-- Table -->
|
|
<v-card-text>
|
|
<div class="d-flex flex-wrap align-center justify-space-between mb-4">
|
|
<div class="d-flex align-center">
|
|
<span class="mr-2 text-subtitle-1">Show</span>
|
|
<v-select
|
|
v-model="itemsPerPage"
|
|
:items="[10, 25, 50, 100]"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details
|
|
style="max-width: 80px;"
|
|
rounded
|
|
class="mr-2"
|
|
></v-select>
|
|
<span class="text-subtitle-1">entries</span>
|
|
</div>
|
|
<div class="d-flex align-center">
|
|
<v-text-field
|
|
v-model="search"
|
|
prepend-inner-icon="mdi-magnify"
|
|
label="Search"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details
|
|
rounded
|
|
clearable
|
|
></v-text-field>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter row: tipe user, account roles, resource roles, groups -->
|
|
<v-row class="mb-4" dense>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="selectedTipeUser"
|
|
:items="availableTipeUsers"
|
|
label="Filter Tipe User"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="selectedAccountRoles"
|
|
:items="availableAccountRoles"
|
|
label="Filter Account Roles"
|
|
multiple
|
|
chips
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="selectedResourceRoles"
|
|
:items="availableResourceRoles"
|
|
label="Filter Resource Roles"
|
|
multiple
|
|
chips
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="selectedGroups"
|
|
:items="availableGroups"
|
|
label="Filter Groups"
|
|
multiple
|
|
chips
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="filteredUsers"
|
|
:search="search"
|
|
:items-per-page="itemsPerPage"
|
|
v-model:page="page"
|
|
:loading="isPending || isSaving"
|
|
loading-text="Memuat data pengguna..."
|
|
class="rounded-lg elevation-0 custom-table"
|
|
>
|
|
<template #item.realmRoles="{ item }">
|
|
<v-chip
|
|
v-for="role in (item.realmRoles || [])"
|
|
:key="role"
|
|
color="blue-lighten-1"
|
|
size="small"
|
|
class="mr-1 mb-1"
|
|
>
|
|
{{ role }}
|
|
</v-chip>
|
|
<span v-if="!item.realmRoles || item.realmRoles.length === 0" class="text-grey">-</span>
|
|
</template>
|
|
|
|
<template #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 #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 #item.groups="{ item }">
|
|
<v-chip
|
|
v-for="group in (item.groups || [])"
|
|
:key="group"
|
|
color="purple-lighten-1"
|
|
size="small"
|
|
class="mr-1 mb-1"
|
|
>
|
|
{{ group }}
|
|
</v-chip>
|
|
<span v-if="!item.groups || item.groups.length === 0" class="text-grey">-</span>
|
|
</template>
|
|
|
|
<template #item.lastLogin="{ item }">
|
|
<span class="text-body-2">
|
|
{{ formatLastLogin(item.lastLogin) }}
|
|
</span>
|
|
<v-tooltip
|
|
v-if="item.lastLogin && item.updatedAt"
|
|
activator="parent"
|
|
location="top"
|
|
>
|
|
Last updated: {{ new Date(item.updatedAt * 1000).toLocaleString('id-ID') }}
|
|
</v-tooltip>
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<v-btn icon color="blue" size="small" class="mr-2" rounded="0" @click="viewItem(item)">
|
|
<v-icon>mdi-eye</v-icon>
|
|
</v-btn>
|
|
<!-- <v-btn icon color="orange" size="small" class="mr-2" rounded="0" @click="editItem(item)">
|
|
<v-icon>mdi-pencil</v-icon>
|
|
</v-btn> -->
|
|
<v-btn icon color="red" size="small" rounded="0" @click="deleteItem(item)">
|
|
<v-icon>mdi-delete</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
|
|
<template #no-data>
|
|
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
|
|
Tidak ada data yang tersedia.
|
|
</v-alert>
|
|
</template>
|
|
|
|
<template #bottom>
|
|
<v-row class="ma-2">
|
|
<v-col cols="12" sm="6" class="d-flex align-center justify-start text-caption text-grey">
|
|
{{ showingEntriesText }}
|
|
</v-col>
|
|
<v-col cols="12" sm="6" class="d-flex align-center justify-end">
|
|
<v-pagination
|
|
v-model="page"
|
|
:length="pageCount"
|
|
rounded="circle"
|
|
:total-visible="5"
|
|
></v-pagination>
|
|
</v-col>
|
|
</v-row>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
|
|
<!-- Custom Modal/Dialog for Confirmation -->
|
|
<v-dialog v-model="showDeleteDialog" max-width="400px">
|
|
<v-card class="pa-4 rounded-lg">
|
|
<v-card-title class="text-h6 font-weight-bold">Hapus Data</v-card-title>
|
|
<v-card-text>Apakah Anda yakin ingin menghapus data **{{ itemToDelete?.namaLengkap }}**?</v-card-text>
|
|
<v-card-actions class="d-flex justify-end">
|
|
<v-btn color="grey-darken-1" variant="text" rounded="0" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
|
|
<v-btn color="red" variant="text" rounded="0" @click="confirmDelete" :loading="isSaving">Hapus</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-snackbar
|
|
v-model="snackbar.show"
|
|
:color="snackbar.color"
|
|
:timeout="snackbar.timeout"
|
|
>
|
|
{{ snackbar.message }}
|
|
</v-snackbar>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
|
|
// ----------------------------------------------------------------------
|
|
// FIX: Explicitly import useAuth from its source path (~/composables/useAuth.ts)
|
|
// This resolves the 'useAuth is not defined' runtime error during SSR/build.
|
|
import { useAuth } from '~/composables/useAuth';
|
|
// ----------------------------------------------------------------------
|
|
|
|
|
|
// Set page metadata (optional, but good practice for a Nuxt page)
|
|
definePageMeta({
|
|
middleware: ['auth'] // Example: requires user to be logged in
|
|
});
|
|
|
|
// Define the expected structure of user data
|
|
interface UserManagementItem {
|
|
id: string | number; // Keycloak UUID
|
|
namaLengkap: string;
|
|
namaUser: string; // Keycloak username
|
|
tipeUser: string; // Custom attribute mapping
|
|
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;
|
|
updatedAt?: number; // Unix timestamp (seconds) when user record was last updated
|
|
}
|
|
|
|
// Data structure for initial API response
|
|
interface InitialData {
|
|
users: UserManagementItem[];
|
|
roles: string[];
|
|
groups: string[];
|
|
}
|
|
|
|
|
|
// --- AUTHENTICATION & INITIAL DATA FETCHING (SSR Compatible) ---
|
|
|
|
// 1. Get Auth State
|
|
// Note: user and isAuthenticated are not used but kept for potential future use
|
|
const { user: _user, isAuthenticated: _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[]>([]);
|
|
|
|
// Function to get current user from JWT token
|
|
const fetchCurrentUser = async () => {
|
|
try {
|
|
const userData = await $fetch('/api/users/current') as any;
|
|
currentUserData.value = userData;
|
|
currentUserRoles.value = (userData?.roles as string[]) || [];
|
|
currentUserGroups.value = (userData?.groups as string[]) || [];
|
|
|
|
// 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 {
|
|
const result = await $fetch('/api/users/sync', { method: 'POST' });
|
|
console.log('✅ User synced successfully:', result);
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('❌ Failed to sync user:', error);
|
|
// Don't show error to user, just log it
|
|
throw error; // Re-throw so caller knows it failed
|
|
}
|
|
};
|
|
|
|
// 4. Use useAsyncData for Server-Side Data Fetching
|
|
const { data: initialData, pending: isPending, error: fetchError, refresh } = await useAsyncData<InitialData>('user-management-data', async () => {
|
|
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 {
|
|
// 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 // Run on client side to ensure session is available
|
|
});
|
|
|
|
// State synchronization: Initialize local refs from useAsyncData result
|
|
const allUserData = ref<UserManagementItem[]>(initialData.value?.users || []);
|
|
const availableRoles = ref<string[]>(initialData.value?.roles || []);
|
|
const availableGroups = ref<string[]>(initialData.value?.groups || []);
|
|
const isSaving = ref(false); // Tracks client-side CRUD operations (Add/Edit/Delete)
|
|
|
|
// Watch the Nuxt data ref and pending state to update local data and loading status
|
|
watch(initialData, (newData: InitialData | null) => {
|
|
if (newData) {
|
|
allUserData.value = newData.users;
|
|
availableRoles.value = newData.roles;
|
|
availableGroups.value = newData.groups;
|
|
}
|
|
}, { deep: true });
|
|
|
|
// Auto-refresh interval and focus handler
|
|
let autoRefreshInterval: NodeJS.Timeout | null = null;
|
|
let focusHandler: (() => void) | null = null;
|
|
|
|
// Function to refresh all users data (sync all users from Keycloak)
|
|
const refreshAllUsers = async () => {
|
|
try {
|
|
isSaving.value = true;
|
|
// Call sync-all endpoint to sync all users from Keycloak
|
|
const result = await $fetch('/api/users/sync-all', { method: 'POST' }) as { stats?: { created?: number; updated?: number } };
|
|
// Refresh the list
|
|
await refresh();
|
|
snackbar.value = {
|
|
show: true,
|
|
message: `Data user berhasil di-refresh! ${result.stats?.created || 0} baru, ${result.stats?.updated || 0} diperbarui`,
|
|
color: 'success',
|
|
timeout: 4000
|
|
};
|
|
} catch (error: any) {
|
|
console.error('Error refreshing users:', error);
|
|
snackbar.value = {
|
|
show: true,
|
|
message: error.data?.message || 'Gagal refresh data user',
|
|
color: 'error',
|
|
timeout: 4000
|
|
};
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
|
|
// Auto-refresh function
|
|
const startAutoRefresh = () => {
|
|
// Clear existing interval if any
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
|
|
// Auto-refresh every 30 seconds
|
|
autoRefreshInterval = setInterval(async () => {
|
|
if (!isSaving.value && !isPending.value) {
|
|
console.log('🔄 Auto-refreshing user data...');
|
|
await refresh();
|
|
}
|
|
}, 30000); // 30 seconds
|
|
};
|
|
|
|
// Stop auto-refresh
|
|
const stopAutoRefresh = () => {
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
autoRefreshInterval = null;
|
|
}
|
|
};
|
|
|
|
// Auto-sync user on mount (client-side only)
|
|
onMounted(async () => {
|
|
// Fetch current user data first
|
|
await fetchCurrentUser();
|
|
|
|
// Sync current user when page loads - this ensures the logged-in user is in the database
|
|
try {
|
|
const syncResult = await syncCurrentUser() as { action?: string } | null;
|
|
console.log('✅ Current user synced:', syncResult);
|
|
|
|
// If current user was just created, refresh the list immediately
|
|
if (syncResult?.action === 'created') {
|
|
console.log('🆕 New user detected, refreshing list...');
|
|
await refresh();
|
|
}
|
|
} catch (error) {
|
|
console.error('⚠️ Failed to sync current user, but continuing...', error);
|
|
}
|
|
|
|
// Check if current user exists in the list
|
|
const currentUserId = currentUserData.value?.id;
|
|
if (currentUserId) {
|
|
const userExists = allUserData.value.some(u => u.id === currentUserId);
|
|
if (!userExists) {
|
|
console.log('⚠️ Current user not found in list, refreshing...');
|
|
await refresh();
|
|
|
|
// If still not found after refresh, try sync-all
|
|
const stillNotFound = !allUserData.value.some(u => u.id === currentUserId);
|
|
if (stillNotFound) {
|
|
console.log('⚠️ Current user still not found, attempting sync-all...');
|
|
try {
|
|
await refreshAllUsers();
|
|
} catch (syncAllError) {
|
|
console.error('❌ Sync-all failed:', syncAllError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh user list after sync to show the current user
|
|
await refresh();
|
|
|
|
// Start auto-refresh
|
|
startAutoRefresh();
|
|
|
|
// Refresh on window focus
|
|
focusHandler = () => {
|
|
if (!isSaving.value && !isPending.value) {
|
|
console.log('🔄 Window focused, refreshing user data...');
|
|
refresh().catch(err => console.error('Error refreshing on focus:', err));
|
|
}
|
|
};
|
|
window.addEventListener('focus', focusHandler);
|
|
});
|
|
|
|
// Cleanup on unmount
|
|
onUnmounted(() => {
|
|
stopAutoRefresh();
|
|
if (focusHandler) {
|
|
window.removeEventListener('focus', focusHandler);
|
|
focusHandler = null;
|
|
}
|
|
});
|
|
|
|
|
|
// --- LOCAL STATE ---
|
|
|
|
// Table configuration
|
|
const headers = ref([
|
|
{ title: 'ID', key: 'id' },
|
|
{ title: 'Nama Lengkap', key: 'namaLengkap', sortable: true },
|
|
{ title: 'Nama User', key: 'namaUser', sortable: true },
|
|
{ title: 'Tipe User', key: 'tipeUser', sortable: true },
|
|
{ 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: 'Last Access', key: 'lastLogin', sortable: true },
|
|
{ title: 'Aksi', key: 'actions', sortable: false },
|
|
]);
|
|
|
|
// Breadcrumbs
|
|
const breadcrumbs = ref([
|
|
{ title: 'Dashboard', disabled: false, href: '/dashboard' },
|
|
{ title: 'Setting', disabled: false, href: '#/setting' },
|
|
{ title: 'User Login', disabled: false, href: '#/setting/userlogin' },
|
|
]);
|
|
|
|
// State for table and pagination
|
|
const itemsPerPage = ref(10);
|
|
const search = ref('');
|
|
const page = ref(1);
|
|
const itemToDelete = ref<UserManagementItem | null>(null);
|
|
|
|
// Filter state
|
|
const selectedTipeUser = ref<string | null>(null);
|
|
const selectedAccountRoles = ref<string[]>([]);
|
|
const selectedResourceRoles = ref<string[]>([]);
|
|
const selectedGroups = ref<string[]>([]);
|
|
|
|
// State for dialog and form
|
|
const showForm = ref(false);
|
|
const readOnly = ref(false);
|
|
const showDeleteDialog = ref(false);
|
|
const isEditMode = ref(false);
|
|
const snackbar = ref({
|
|
show: false,
|
|
message: '',
|
|
color: 'success',
|
|
timeout: 3000,
|
|
});
|
|
|
|
// Edited item structure
|
|
const emptyItem: UserManagementItem = {
|
|
id: '',
|
|
namaLengkap: '',
|
|
namaUser: '',
|
|
tipeUser: '',
|
|
lastLogin: null,
|
|
password: '',
|
|
roles: [],
|
|
realmRoles: [],
|
|
accountRoles: [],
|
|
resourceRoles: [],
|
|
groups: []
|
|
};
|
|
const editedItem = ref<UserManagementItem>(Object.assign({}, emptyItem));
|
|
|
|
|
|
// --- COMPUTED PROPERTIES ---
|
|
|
|
// Options for filters, derived from current user data
|
|
const availableTipeUsers = computed<string[]>(() => {
|
|
const set = new Set<string>();
|
|
allUserData.value.forEach((u) => {
|
|
if (u.tipeUser) set.add(u.tipeUser);
|
|
});
|
|
return Array.from(set).sort();
|
|
});
|
|
|
|
const availableAccountRoles = computed<string[]>(() => {
|
|
const set = new Set<string>();
|
|
allUserData.value.forEach((u) => {
|
|
(u.accountRoles || []).forEach((r) => set.add(r));
|
|
});
|
|
return Array.from(set).sort();
|
|
});
|
|
|
|
const availableResourceRoles = computed<string[]>(() => {
|
|
const set = new Set<string>();
|
|
allUserData.value.forEach((u) => {
|
|
(u.resourceRoles || []).forEach((r) => set.add(r));
|
|
});
|
|
return Array.from(set).sort();
|
|
});
|
|
|
|
// Filtered users list used by table & pagination
|
|
const filteredUsers = computed<UserManagementItem[]>(() => {
|
|
return allUserData.value.filter((user) => {
|
|
// Filter by tipe user (single select)
|
|
if (selectedTipeUser.value && user.tipeUser !== selectedTipeUser.value) {
|
|
return false;
|
|
}
|
|
|
|
// Filter by account roles (multi-select, match any)
|
|
if (selectedAccountRoles.value.length > 0) {
|
|
const userAccountRoles = user.accountRoles || [];
|
|
const hasMatch = selectedAccountRoles.value.some((role) =>
|
|
userAccountRoles.includes(role)
|
|
);
|
|
if (!hasMatch) return false;
|
|
}
|
|
|
|
// Filter by resource roles (multi-select, match any)
|
|
if (selectedResourceRoles.value.length > 0) {
|
|
const userResourceRoles = user.resourceRoles || [];
|
|
const hasMatch = selectedResourceRoles.value.some((role) =>
|
|
userResourceRoles.includes(role)
|
|
);
|
|
if (!hasMatch) return false;
|
|
}
|
|
|
|
// Filter by groups (multi-select, match any)
|
|
if (selectedGroups.value.length > 0) {
|
|
const userGroups = user.groups || [];
|
|
const hasMatch = selectedGroups.value.some((g) =>
|
|
userGroups.includes(g)
|
|
);
|
|
if (!hasMatch) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
});
|
|
|
|
// Format last access time (data from Keycloak)
|
|
const formatLastLogin = (timestamp: number | null | undefined): string => {
|
|
// Check if timestamp is valid (not null, not undefined, not 0)
|
|
if (!timestamp || timestamp === 0 || isNaN(timestamp)) {
|
|
return 'Belum pernah login';
|
|
}
|
|
|
|
try {
|
|
// Handle both seconds and milliseconds timestamps
|
|
let date: Date;
|
|
if (timestamp > 10000000000) {
|
|
// Timestamp is in milliseconds
|
|
date = new Date(timestamp);
|
|
} else {
|
|
// Timestamp is in seconds
|
|
date = new Date(timestamp * 1000);
|
|
}
|
|
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return 'Belum pernah login';
|
|
}
|
|
|
|
// Format like: "10 Desember 2025 pukul 13.00"
|
|
return date.toLocaleString('id-ID', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}).replace(',', ' pukul');
|
|
} catch (error) {
|
|
console.error('Error formatting last login:', error, timestamp);
|
|
return 'Belum pernah login';
|
|
}
|
|
};
|
|
|
|
const pageCount = computed(() => {
|
|
// Use filtered data length so pagination matches applied filters
|
|
return Math.ceil(filteredUsers.value.length / itemsPerPage.value) || 1;
|
|
});
|
|
|
|
const showingEntriesText = computed(() => {
|
|
const total = filteredUsers.value.length;
|
|
if (total === 0) return 'Showing 0 to 0 of 0 entries';
|
|
const start = (page.value - 1) * itemsPerPage.value + 1;
|
|
const end = Math.min(page.value * itemsPerPage.value, total);
|
|
return `Showing ${start} to ${end} of ${total} entries`;
|
|
});
|
|
|
|
|
|
// --- FORM & ACTION FUNCTIONS ---
|
|
|
|
const resetForm = () => {
|
|
editedItem.value = Object.assign({}, emptyItem);
|
|
};
|
|
|
|
// Unused function - no button in UI to trigger this
|
|
// If add user functionality is needed, add a button that calls this function
|
|
// const showAddForm = () => {
|
|
// resetForm();
|
|
// isEditMode.value = false;
|
|
// readOnly.value = false;
|
|
// showForm.value = true;
|
|
// };
|
|
|
|
const viewItem = (item: UserManagementItem) => {
|
|
editedItem.value = Object.assign({}, item);
|
|
isEditMode.value = false;
|
|
readOnly.value = true;
|
|
showForm.value = true;
|
|
};
|
|
|
|
// Unused function - edit button has been removed from UI
|
|
// If edit functionality is needed, uncomment this and add edit button back
|
|
// const editItem = (item: UserManagementItem) => {
|
|
// editedItem.value = Object.assign({}, item);
|
|
// editedItem.value.password = ''; // Clear password field for security
|
|
// isEditMode.value = true;
|
|
// readOnly.value = false;
|
|
// showForm.value = true;
|
|
// };
|
|
|
|
const cancelForm = () => {
|
|
showForm.value = false;
|
|
resetForm();
|
|
};
|
|
|
|
/**
|
|
* Handles form submission (Add or Edit) using Nuxt's $fetch for client-side API call.
|
|
*/
|
|
const saveItem = async () => {
|
|
if (!editedItem.value.namaLengkap || !editedItem.value.namaUser || !editedItem.value.tipeUser) {
|
|
snackbar.value = { show: true, message: 'Nama Lengkap, Nama User, dan Tipe User wajib diisi!', color: 'error', timeout: 3000 };
|
|
return;
|
|
}
|
|
|
|
// Password validation removed because password field is disabled
|
|
// If add user functionality is enabled in the future, uncomment and enable password field
|
|
// if (!isEditMode.value && !editedItem.value.password) {
|
|
// snackbar.value = { show: true, message: 'Password wajib diisi saat membuat pengguna baru!', color: 'error', timeout: 3000 };
|
|
// return;
|
|
// }
|
|
|
|
isSaving.value = true;
|
|
|
|
// Prepare payload
|
|
const payload = {
|
|
namaLengkap: editedItem.value.namaLengkap,
|
|
username: editedItem.value.namaUser,
|
|
tipeUser: editedItem.value.tipeUser,
|
|
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 })
|
|
};
|
|
|
|
try {
|
|
if (isEditMode.value) {
|
|
// EDIT: PATCH to update existing user by ID (Keycloak UUID)
|
|
await $fetch(`/api/users/${editedItem.value.id}`, {
|
|
method: 'PATCH',
|
|
body: payload,
|
|
});
|
|
snackbar.value = { show: true, message: 'Data pengguna berhasil diperbarui!', color: 'success', timeout: 3000 };
|
|
} else {
|
|
// ADD: POST to create new user
|
|
await $fetch('/api/users/create', {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
snackbar.value = { show: true, message: 'Pengguna baru berhasil ditambahkan!', color: 'success', timeout: 3000 };
|
|
}
|
|
|
|
// Re-fetch all initial data to update the table reactively
|
|
await refresh();
|
|
cancelForm();
|
|
|
|
} catch (error: any) {
|
|
console.error('API Error during save:', error);
|
|
// Nuxt's $fetch error handling
|
|
const errorMessage = error.data?.message || 'Terjadi kesalahan saat menyimpan data pengguna. Cek log server.';
|
|
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteItem = (item: UserManagementItem) => {
|
|
itemToDelete.value = item;
|
|
showDeleteDialog.value = true;
|
|
};
|
|
|
|
const closeDeleteDialog = () => {
|
|
showDeleteDialog.value = false;
|
|
itemToDelete.value = null;
|
|
};
|
|
|
|
/**
|
|
* Confirms and executes the user deletion using Nuxt's $fetch.
|
|
*/
|
|
const confirmDelete = async () => {
|
|
if (!itemToDelete.value || !itemToDelete.value.id) {
|
|
closeDeleteDialog();
|
|
return;
|
|
}
|
|
|
|
isSaving.value = true;
|
|
|
|
try {
|
|
// DELETE request to the backend using the user's ID
|
|
await $fetch(`/api/users/${itemToDelete.value.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
snackbar.value = { show: true, message: `Data pengguna ${itemToDelete.value.namaLengkap} berhasil dihapus!`, color: 'warning', timeout: 3000 };
|
|
|
|
// Re-fetch data to update the table
|
|
await refresh();
|
|
|
|
} catch (error: any) {
|
|
console.error('API Error during delete:', error);
|
|
const errorMessage = error.data?.message || 'Gagal menghapus data pengguna.';
|
|
snackbar.value = { show: true, message: errorMessage, color: 'error', timeout: 5000 };
|
|
} finally {
|
|
closeDeleteDialog();
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
// Colors from Design System
|
|
$primary-700: #3556AE;
|
|
$primary-600: #3A61C9;
|
|
|
|
$success-700: #1B6E53;
|
|
$success-600: #009262;
|
|
$neutral-100: #FFFFFF;
|
|
$neutral-800: #4D4D4D;
|
|
|
|
// Font Family & Weights
|
|
$font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
$font-weight-regular: 400;
|
|
$font-weight-medium: 500;
|
|
$font-weight-semibold: 600;
|
|
|
|
// Apply font family
|
|
* {
|
|
font-family: $font-family-base;
|
|
}
|
|
|
|
// ============================================
|
|
// PAGE HEADER (matching MasterKlinikRuang style)
|
|
// ============================================
|
|
.page-header {
|
|
background: linear-gradient(135deg, $primary-600 0%, $primary-700 100%);
|
|
border-radius: 0 !important;
|
|
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.2);
|
|
}
|
|
|
|
.header-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 28px;
|
|
height: 80px;
|
|
color: $neutral-100;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-icon {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
margin-right: 16px;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 32px;
|
|
line-height: 40px;
|
|
font-weight: $font-weight-semibold;
|
|
margin: 0;
|
|
color: $neutral-100;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.page-subtitle {
|
|
margin: 2px 0 0 0;
|
|
opacity: 0.9;
|
|
font-size: 15px;
|
|
line-height: 22px;
|
|
font-weight: $font-weight-regular;
|
|
color: $neutral-100;
|
|
}
|
|
|
|
.header-stats {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-chip {
|
|
color: $primary-600 !important;
|
|
font-weight: $font-weight-semibold;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.add-btn {
|
|
font-weight: $font-weight-semibold;
|
|
text-transform: none;
|
|
letter-spacing: 0.5px;
|
|
font-size: 16px;
|
|
line-height: 24px;
|
|
color: $primary-600 !important;
|
|
}
|
|
|
|
// ============================================
|
|
// ACTION BAR
|
|
// ============================================
|
|
.action-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 20px;
|
|
background: $neutral-100;
|
|
border-radius: 12px;
|
|
border: 1px solid $neutral-400;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.action-bar-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.action-btn {
|
|
font-weight: $font-weight-semibold;
|
|
text-transform: none;
|
|
letter-spacing: 0.5px;
|
|
font-size: 16px;
|
|
line-height: 24px;
|
|
}
|
|
|
|
// ============================================
|
|
// CUSTOM TABLE STYLING
|
|
// ============================================
|
|
.custom-table :deep(table) {
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.custom-table :deep(th) {
|
|
background-color: #f5f5f5 !important;
|
|
font-weight: bold;
|
|
font-size: 0.875rem; /* text-sm */
|
|
}
|
|
|
|
.custom-table :deep(td) {
|
|
padding-top: 12px !important;
|
|
padding-bottom: 12px !important;
|
|
}
|
|
|
|
// ============================================
|
|
// MODERN DIALOG STYLING
|
|
// ============================================
|
|
.dialog-header {
|
|
background: linear-gradient(135deg, var(--v-theme-primary) 0%, var(--v-theme-primary-darken-1) 100%);
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.dialog-header.bg-blue-darken-3 {
|
|
background: linear-gradient(135deg, var(--v-theme-primary) 0%, var(--v-theme-primary-darken-1) 100%);
|
|
}
|
|
|
|
.dialog-header.bg-orange-darken-2 {
|
|
background: linear-gradient(135deg, var(--v-theme-primary) 0%, var(--v-theme-primary-darken-1) 100%);
|
|
}
|
|
|
|
.dialog-header.bg-green-darken-2 {
|
|
background: linear-gradient(135deg, var(--v-theme-primary) 0%, var(--v-theme-primary-darken-1) 100%);
|
|
}
|
|
|
|
// ============================================
|
|
// FORM FIELD STYLING
|
|
// ============================================
|
|
:deep(.v-field__prepend-inner) {
|
|
padding-right: 12px;
|
|
}
|
|
|
|
:deep(.v-field__prepend-inner .v-icon) {
|
|
opacity: 0.7;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
:deep(.v-field--focused .v-field__prepend-inner .v-icon) {
|
|
opacity: 1;
|
|
}
|
|
|
|
// ============================================
|
|
// SECTION DIVIDER STYLING
|
|
// ============================================
|
|
:deep(.v-divider) {
|
|
border-color: rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
// ============================================
|
|
// RESPONSIVE
|
|
// ============================================
|
|
@media (max-width: 768px) {
|
|
.header-content {
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.header-stats {
|
|
width: 100%;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|