Files
web-antrean/pages/Setting/UserLogin.vue
T

787 lines
26 KiB
Vue

<template>
<div class="user-login-page pa-4">
<!-- 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="currentUserData ? 'green' : 'red'" size="small" class="ml-2 mr-1">
{{ currentUserData ? 'Authenticated' : 'Unauthenticated' }}
</v-chip>
(Username: <strong>{{ currentUserData?.namaUser || currentUserData?.namaLengkap || 'N/A' }}</strong>)
</div>
<v-breadcrumbs :items="breadcrumbs" class="pl-0">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
</v-breadcrumbs>
<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>
<v-card v-if="showForm" class="pa-4 rounded-lg elevation-2">
<v-card-title class="text-h5 font-weight-bold mb-4">
{{ isEditMode ? 'Edit Pengguna' : readOnly ? 'Detail Pengguna' : 'Tambah Pengguna' }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama Lengkap</v-label>
<v-text-field
v-model="editedItem.namaLengkap"
placeholder="Masukkan Nama Lengkap"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Nama User</v-label>
<v-text-field
v-model="editedItem.namaUser"
placeholder="Masukkan Nama User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly || isEditMode"
:hint="isEditMode ? 'Username tidak dapat diubah' : ''"
persistent-hint
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Tipe User</v-label>
<v-select
v-model="editedItem.tipeUser"
:items="['Super Admin', 'Admin', 'Loket', 'Klinik', 'Admin Barcode', 'INOVA', 'Ranap', 'Report Only', 'Farmasi', 'Manager']"
placeholder="Pilih Tipe User"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-label class="font-weight-bold">Last Access</v-label>
<v-text-field
:value="formatLastLogin(editedItem.lastLogin)"
placeholder="Last Access Time"
variant="outlined"
density="comfortable"
class="mb-2"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-label class="font-weight-bold">Realm Roles</v-label>
<v-select
v-model="editedItem.realmRoles"
:items="availableRoles"
multiple
chips
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
v-model="editedItem.groups"
:items="availableGroups"
multiple
chips
placeholder="Pilih Groups Pengguna"
variant="outlined"
density="comfortable"
class="mb-2"
:readonly="readOnly"
></v-select>
</v-col>
</v-row>
<v-row v-if="!readOnly">
<v-col cols="12">
<v-label class="font-weight-bold">{{ isEditMode ? 'Ganti Password (Kosongkan jika tidak diubah)' : 'Password' }}</v-label>
<v-text-field
v-model="editedItem.password"
placeholder="Masukkan Password"
type="password"
variant="outlined"
density="comfortable"
class="mb-2"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex justify-start pa-4">
<v-btn
color="grey-darken-1"
variant="flat"
class="text-capitalize rounded-lg mr-2"
@click="cancelForm"
>
{{ readOnly ? 'Tutup' : 'Batal' }}
</v-btn>
<v-btn
v-if="!readOnly"
color="orange-darken-2"
variant="flat"
class="text-capitalize rounded-lg"
@click="saveItem"
:loading="isSaving"
:disabled="isSaving"
>
Submit
</v-btn>
</v-card-actions>
</v-card>
<div v-else>
<v-card class="d-flex flex-wrap justify-space-between align-center pa-4 mb-4 rounded-lg bg-blue-darken-2 text-white elevation-2">
<v-card-title class="d-flex align-center text-h5 font-weight-bold pa-0">
<v-icon icon="mdi-account-group-outline" class="mr-2" size="40"></v-icon>
<span>User Login Management</span>
</v-card-title>
<v-btn
color="white"
prepend-icon="mdi-refresh"
rounded
class="text-capitalize"
@click="refreshAllUsers"
:disabled="isPending || isSaving"
:loading="isSaving"
variant="outlined"
>
Refresh Data
</v-btn>
</v-card>
<v-card class="pa-4 rounded-lg elevation-2">
<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>
<v-data-table
:headers="headers"
:items="allUserData"
: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 v-slot: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 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 || [])"
: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 v-slot:item.lastLogin="{ item }">
<span class="text-body-2">
{{ formatLastLogin(item.lastLogin) }}
</span>
</template>
<template v-slot:item.actions="{ item }">
<v-btn icon color="blue" size="small" class="mr-2" @click="viewItem(item)">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon color="orange" size="small" class="mr-2" @click="editItem(item)">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="red" size="small" @click="deleteItem(item)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template v-slot:no-data>
<v-alert :value="true" color="grey-lighten-3" icon="mdi-information">
Tidak ada data yang tersedia.
</v-alert>
</template>
<template v-slot: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" @click="closeDeleteDialog" :disabled="isSaving">Batal</v-btn>
<v-btn color="red" variant="text" @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>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } 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;
}
// Data structure for initial API response
interface InitialData {
users: UserManagementItem[];
roles: string[];
groups: string[];
}
// --- AUTHENTICATION & INITIAL DATA FETCHING (SSR Compatible) ---
// 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[]>([]);
// 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 () => {
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 // 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) => {
if (newData) {
allUserData.value = newData.users;
availableRoles.value = newData.roles;
availableGroups.value = newData.groups;
}
}, { 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 ---
// 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);
// 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 ---
// 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
// for a simple calculation of total pages based on all data.
return Math.ceil(allUserData.value.length / itemsPerPage.value);
});
const showingEntriesText = computed(() => {
const total = allUserData.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);
};
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;
};
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;
}
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>
/* Custom Table Styling for better visual separation */
.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;
}
</style>