first commit
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { ref } from "vue";
|
||||
import type { OdontogramData } from "~/types/apps/medical/odontogram";
|
||||
|
||||
const STORAGE_KEY = "odontogramData";
|
||||
|
||||
const savedData = ref<OdontogramData | null>(null);
|
||||
|
||||
function saveData(data: OdontogramData) {
|
||||
try {
|
||||
// Convert reactive data to plain JS object before saving
|
||||
const plainData = JSON.parse(JSON.stringify(data));
|
||||
console.log("Saving odontogram data to localStorage (plain):", plainData);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(plainData));
|
||||
savedData.value = plainData;
|
||||
} catch (error) {
|
||||
console.error("Failed to save odontogram data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function loadData(): OdontogramData | null {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
console.log("Loading odontogram data from localStorage:", data);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
if (isOdontogramData(parsed)) {
|
||||
savedData.value = parsed;
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load odontogram data:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const clearData = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
savedData.value = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error clearing odontogram data:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = (data: OdontogramData) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json"
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `odontogram_${new Date().toISOString().split("T")[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importData = (file: File): Promise<OdontogramData | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string);
|
||||
if (isOdontogramData(data)) {
|
||||
resolve(data);
|
||||
} else {
|
||||
console.error("Imported data is not valid OdontogramData");
|
||||
resolve(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing imported data:", error);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
function isOdontogramData(data: any): data is OdontogramData {
|
||||
return (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
Array.isArray(data.conditions) &&
|
||||
typeof data.metadata === "object" &&
|
||||
data.metadata !== null &&
|
||||
(data.currentMode === undefined || typeof data.currentMode === "number")
|
||||
);
|
||||
}
|
||||
|
||||
export function useDataStorage() {
|
||||
return {
|
||||
saveData,
|
||||
loadData,
|
||||
clearData,
|
||||
exportData,
|
||||
importData,
|
||||
savedData
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
import type { MenuItem } from '~/types/menu'
|
||||
|
||||
export const useMenuManagement = () => {
|
||||
const menuItems = ref<MenuItem[]>([])
|
||||
const menuOptions = ref<string[]>([])
|
||||
const references = ref<string[]>([])
|
||||
const selectedReference = ref('Referensi')
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Load menus from JSON file
|
||||
const loadMenus = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; data: { menus: MenuItem[]; menuOptions: string[]; references: string[] } }>('/api/menus/list')
|
||||
const data = response.data
|
||||
menuItems.value = data.menus || []
|
||||
menuOptions.value = data.menuOptions || []
|
||||
references.value = data.references || []
|
||||
} catch (error) {
|
||||
console.error('Error loading menus:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save single menu item
|
||||
const saveMenu = async (menuItem: MenuItem) => {
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; data: MenuItem }>('/api/menus/save', {
|
||||
method: 'POST',
|
||||
body: { menuItem, reference: selectedReference.value }
|
||||
})
|
||||
const data = response.data
|
||||
|
||||
// Update local state
|
||||
const index = menuItems.value.findIndex(item => item.id === menuItem.id)
|
||||
if (index >= 0) {
|
||||
menuItems.value[index] = data
|
||||
} else {
|
||||
menuItems.value.push(data)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error saving menu:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Delete menu item
|
||||
const deleteMenu = async (menuId: string) => {
|
||||
try {
|
||||
await $fetch('/api/menus/delete', {
|
||||
method: 'DELETE',
|
||||
body: { menuId, reference: selectedReference.value }
|
||||
})
|
||||
|
||||
// Remove from local state
|
||||
const removeFromItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items.filter(item => {
|
||||
if (item.id === menuId) return false
|
||||
if (item.children) {
|
||||
item.children = removeFromItems(item.children)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.value = removeFromItems(menuItems.value)
|
||||
} catch (error) {
|
||||
console.error('Error deleting menu:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Update menu order
|
||||
const updateMenuOrder = async (newOrder: MenuItem[]) => {
|
||||
try {
|
||||
await $fetch('/api/menus/reorder', {
|
||||
method: 'POST',
|
||||
body: { menus: newOrder, reference: selectedReference.value }
|
||||
})
|
||||
|
||||
menuItems.value = newOrder
|
||||
} catch (error) {
|
||||
console.error('Error updating menu order:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Vue page from menu
|
||||
const generatePage = async (menuItem: MenuItem) => {
|
||||
try {
|
||||
const pageData = {
|
||||
name: menuItem.title.replace(/\s+/g, ''),
|
||||
path: menuItem.url,
|
||||
templateType: 'default',
|
||||
metadata: {
|
||||
title: menuItem.title,
|
||||
description: `Page for ${menuItem.title}`,
|
||||
keywords: [menuItem.title.toLowerCase()]
|
||||
},
|
||||
content: {
|
||||
title: menuItem.title,
|
||||
icon: menuItem.icon
|
||||
}
|
||||
}
|
||||
|
||||
await $fetch('/api/pages/generate', {
|
||||
method: 'POST',
|
||||
body: pageData
|
||||
})
|
||||
|
||||
return pageData
|
||||
} catch (error) {
|
||||
console.error('Error generating page:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menuItems: readonly(menuItems),
|
||||
menuOptions: readonly(menuOptions),
|
||||
references: readonly(references),
|
||||
selectedReference,
|
||||
isLoading: readonly(isLoading),
|
||||
loadMenus,
|
||||
saveMenu,
|
||||
deleteMenu,
|
||||
updateMenuOrder,
|
||||
generatePage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// composables/usePatientForm.ts
|
||||
import type { FhirPatient, FhirHumanName } from "~/types/fhir/humanName";
|
||||
|
||||
interface PatientFormData {
|
||||
dataDiri: {
|
||||
namaLengkap: string;
|
||||
jenisKelamin: string;
|
||||
tanggalLahir: string;
|
||||
nomorIdentitas: string;
|
||||
jenisIdentitas: string;
|
||||
fhirName?: FhirHumanName | null;
|
||||
};
|
||||
// ... other interfaces
|
||||
}
|
||||
|
||||
export const usePatientForm = () => {
|
||||
const convertToFhirPatient = (formData: PatientFormData): FhirPatient => {
|
||||
const fhirPatient: FhirPatient = {
|
||||
resourceType: "Patient",
|
||||
identifier: [],
|
||||
active: true,
|
||||
name: [],
|
||||
telecom: [],
|
||||
gender: undefined,
|
||||
birthDate: undefined,
|
||||
address: []
|
||||
};
|
||||
|
||||
// Add parsed FHIR name
|
||||
if (formData.dataDiri.fhirName) {
|
||||
fhirPatient.name!.push(formData.dataDiri.fhirName);
|
||||
}
|
||||
|
||||
// Gender mapping
|
||||
if (formData.dataDiri.jenisKelamin) {
|
||||
fhirPatient.gender =
|
||||
formData.dataDiri.jenisKelamin === "L" ? "male" : "female";
|
||||
}
|
||||
|
||||
// Birth date
|
||||
if (formData.dataDiri.tanggalLahir) {
|
||||
fhirPatient.birthDate = formData.dataDiri.tanggalLahir;
|
||||
}
|
||||
|
||||
// Identifiers
|
||||
if (formData.dataDiri.nomorIdentitas) {
|
||||
fhirPatient.identifier!.push({
|
||||
use: "official",
|
||||
type: {
|
||||
coding: [
|
||||
{
|
||||
system: "http://terminology.hl7.org/CodeSystem/v2-0203",
|
||||
code:
|
||||
formData.dataDiri.jenisIdentitas === "KTP" ? "NNESP" : "PPN",
|
||||
display: formData.dataDiri.jenisIdentitas
|
||||
}
|
||||
]
|
||||
},
|
||||
value: formData.dataDiri.nomorIdentitas
|
||||
});
|
||||
}
|
||||
|
||||
return fhirPatient;
|
||||
};
|
||||
|
||||
return {
|
||||
convertToFhirPatient
|
||||
};
|
||||
};
|
||||
interface PersonalInfo {
|
||||
nik?: string;
|
||||
fullName?: string;
|
||||
birthPlace?: string;
|
||||
birthDate?: string;
|
||||
gender?: string;
|
||||
}
|
||||
|
||||
interface ContactInfo {
|
||||
phone?: string;
|
||||
address?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
// export const usePatientForm = () => {
|
||||
// const validatePersonalInfo = (data: PersonalInfo) => {
|
||||
// const errors: Record<string, string> = {};
|
||||
|
||||
// if (!data.nik) errors.nik = "NIK wajib diisi";
|
||||
// if (!data.fullName) errors.fullName = "Nama lengkap wajib diisi";
|
||||
// if (!data.birthPlace) errors.birthPlace = "Tempat lahir wajib diisi";
|
||||
// if (!data.birthDate) errors.birthDate = "Tanggal lahir wajib diisi";
|
||||
// if (!data.gender) errors.gender = "Jenis kelamin wajib diisi";
|
||||
|
||||
// return {
|
||||
// valid: Object.keys(errors).length === 0,
|
||||
// errors
|
||||
// };
|
||||
// };
|
||||
|
||||
// const validateContactInfo = (data: ContactInfo) => {
|
||||
// const errors: Record<string, string> = {};
|
||||
|
||||
// if (!data.phone) errors.phone = "Nomor telepon wajib diisi";
|
||||
// if (!data.address) errors.address = "Alamat wajib diisi";
|
||||
// if (!data.province) errors.province = "Provinsi wajib diisi";
|
||||
// if (!data.city) errors.city = "Kota wajib diisi";
|
||||
|
||||
// return {
|
||||
// valid: Object.keys(errors).length === 0,
|
||||
// errors
|
||||
// };
|
||||
// };
|
||||
|
||||
// const generateMRNumber = () => {
|
||||
// const date = new Date();
|
||||
// const year = date.getFullYear().toString().substr(-2);
|
||||
// const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
// const random = Math.floor(Math.random() * 10000)
|
||||
// .toString()
|
||||
// .padStart(4, "0");
|
||||
|
||||
// return `MR${year}${month}${random}`;
|
||||
// };
|
||||
|
||||
// return {
|
||||
// validatePersonalInfo,
|
||||
// validateContactInfo,
|
||||
// generateMRNumber
|
||||
// };
|
||||
// };
|
||||
@@ -0,0 +1,606 @@
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
import type { ExtendedUser } from "~/types/auth";
|
||||
import { computed, ref, watch, readonly } from "vue";
|
||||
|
||||
export interface UseUserInfoReturnType {
|
||||
// State
|
||||
isAuthenticated: ComputedRef<boolean>;
|
||||
isLoading: ComputedRef<boolean>;
|
||||
user: ComputedRef<ExtendedUser | null>;
|
||||
userRoles: ComputedRef<string[]>;
|
||||
clientRoles: ComputedRef<string[]>;
|
||||
accessToken: ComputedRef<string>;
|
||||
sessionExpires: ComputedRef<string | null>;
|
||||
isIdle: Ref<boolean>;
|
||||
|
||||
// Raw data for debugging
|
||||
data: Ref<any>;
|
||||
status: Ref<string>;
|
||||
|
||||
// Session data from [...].ts
|
||||
idToken: ComputedRef<string>;
|
||||
refreshToken: ComputedRef<string>;
|
||||
accessTokenPayload: ComputedRef<any>;
|
||||
expiresAt: ComputedRef<number | null>;
|
||||
sessionScope: ComputedRef<string>;
|
||||
jwtToken: ComputedRef<string>;
|
||||
|
||||
// Role checking functions
|
||||
hasRole: (role: string) => boolean;
|
||||
hasClientRole: (role: string) => boolean;
|
||||
hasAnyRole: (roles: string[]) => boolean;
|
||||
isAdmin: ComputedRef<boolean>;
|
||||
|
||||
// Session monitoring functions
|
||||
updateActivity: () => void;
|
||||
startSessionMonitoring: () => (() => void) | null;
|
||||
stopSessionMonitoring: () => void;
|
||||
|
||||
// Authentication actions
|
||||
login: (provider?: string) => Promise<void>;
|
||||
logout: (options?: any) => Promise<void>;
|
||||
fullLogout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
forceRefreshSession: () => Promise<void>;
|
||||
isKeycloakSessionActive: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useUserInfo = (): UseUserInfoReturnType => {
|
||||
const { data, status, signIn, signOut, getSession } = useAuth();
|
||||
|
||||
// Internal writable refs for manual setting
|
||||
const internalData = ref(data.value);
|
||||
const internalStatus = ref(status.value);
|
||||
|
||||
// Sync internal refs with useAuth data and status
|
||||
watch(
|
||||
() => data.value,
|
||||
(newVal) => {
|
||||
internalData.value = newVal;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => status.value,
|
||||
(newVal) => {
|
||||
internalStatus.value = newVal;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Session monitoring state
|
||||
const lastActivity = ref(Date.now());
|
||||
const isIdle = ref(false);
|
||||
const tabVisible = ref(true);
|
||||
|
||||
// Constants
|
||||
const IDLE_TIMEOUT = 15 * 60 * 1000; // 15 menit
|
||||
const SESSION_CHECK_INTERVAL = 5 * 60 * 1000; // 5 menit
|
||||
const TAB_INACTIVE_THRESHOLD = 10 * 60 * 1000; // 10 menit
|
||||
|
||||
// Basic computed properties
|
||||
const isAuthenticated = computed(
|
||||
() => internalStatus.value === "authenticated"
|
||||
);
|
||||
const isLoading = computed(() => internalStatus.value === "loading");
|
||||
|
||||
// User info from session
|
||||
const user: ComputedRef<ExtendedUser | null> = computed(() => {
|
||||
if (internalData.value?.user) {
|
||||
return internalData.value.user as ExtendedUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Session data dari [...].ts callbacks - INILAH DATA YANG AKAN DITAMPILKAN
|
||||
const idToken = computed(() => (internalData.value as any)?.id_token || "");
|
||||
|
||||
const accessToken = computed(
|
||||
() => (internalData.value as any)?.access_token || ""
|
||||
);
|
||||
|
||||
const refreshToken = computed(
|
||||
() => (internalData.value as any)?.refresh_token || ""
|
||||
);
|
||||
|
||||
const accessTokenPayload = computed(
|
||||
() => (internalData.value as any)?.access_token_payload || null
|
||||
);
|
||||
|
||||
const expiresAt = computed(
|
||||
() => (internalData.value as any)?.expires_at || null
|
||||
);
|
||||
|
||||
const sessionScope = computed(() => (internalData.value as any)?.scope || "");
|
||||
|
||||
const jwtToken = computed(() => (internalData.value as any)?.jwt || "");
|
||||
|
||||
// User roles dari session
|
||||
const userRoles = computed(() => {
|
||||
return (internalData.value?.user as any)?.roles || [];
|
||||
});
|
||||
|
||||
const clientRoles = computed(() => {
|
||||
return (internalData.value?.user as any)?.client_roles || [];
|
||||
});
|
||||
|
||||
// Session expires
|
||||
const sessionExpires = computed(() => data.value?.expires || null);
|
||||
|
||||
// Activity tracking functions
|
||||
const updateActivity = () => {
|
||||
if (process.client) {
|
||||
lastActivity.value = Date.now();
|
||||
isIdle.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkIdleStatus = () => {
|
||||
if (!process.client) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastActivity = now - lastActivity.value;
|
||||
if (
|
||||
timeSinceLastActivity >= IDLE_TIMEOUT &&
|
||||
tabVisible.value &&
|
||||
isAuthenticated.value
|
||||
) {
|
||||
isIdle.value = true;
|
||||
handleIdleTimeout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdleTimeout = async () => {
|
||||
if (!process.client) return;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (
|
||||
session &&
|
||||
session.expires &&
|
||||
new Date(session.expires) > new Date()
|
||||
) {
|
||||
await navigateTo("/auth/login?reason=idle&continue=true");
|
||||
} else {
|
||||
await logout({ reason: "expired" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Idle timeout handling error:", error);
|
||||
await logout({ reason: "expired" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = async () => {
|
||||
if (!process.client) return;
|
||||
tabVisible.value = !document.hidden;
|
||||
if (!document.hidden) {
|
||||
updateActivity();
|
||||
const inactiveTime = Date.now() - lastActivity.value;
|
||||
if (inactiveTime >= TAB_INACTIVE_THRESHOLD && isAuthenticated.value) {
|
||||
await handleTabReactivation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabReactivation = async () => {
|
||||
if (!process.client) return;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (
|
||||
session &&
|
||||
session.expires &&
|
||||
new Date(session.expires) > new Date()
|
||||
) {
|
||||
await navigateTo("/auth/login?reason=tab_inactive&continue=true");
|
||||
} else {
|
||||
await navigateTo("/auth/login?reason=session_expired");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Tab reactivation error:", error);
|
||||
await navigateTo("/auth/login?reason=error");
|
||||
}
|
||||
};
|
||||
|
||||
const startSessionMonitoring = (): (() => void) | null => {
|
||||
if (!process.client) {
|
||||
console.warn("Session monitoring requires client-side environment");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
console.warn("Session monitoring requires authenticated user");
|
||||
return null;
|
||||
}
|
||||
|
||||
const events = [
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"keydown",
|
||||
"scroll",
|
||||
"touchstart",
|
||||
"click"
|
||||
];
|
||||
const activityHandler = () => updateActivity();
|
||||
|
||||
events.forEach((event) => {
|
||||
document.addEventListener(event, activityHandler, { passive: true });
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const idleInterval = setInterval(checkIdleStatus, 60 * 1000);
|
||||
|
||||
const sessionInterval = setInterval(async () => {
|
||||
if (isAuthenticated.value) {
|
||||
try {
|
||||
await getSession();
|
||||
} catch (error) {
|
||||
console.error("Session check failed:", error);
|
||||
clearInterval(sessionInterval);
|
||||
await logout({ reason: "expired" });
|
||||
}
|
||||
}
|
||||
}, SESSION_CHECK_INTERVAL);
|
||||
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
document.removeEventListener(event, activityHandler);
|
||||
});
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
clearInterval(idleInterval);
|
||||
clearInterval(sessionInterval);
|
||||
};
|
||||
};
|
||||
|
||||
// **PERBAIKAN: Stop Session Monitoring Function**
|
||||
const stopSessionMonitoring = (): void => {
|
||||
// Implementation bisa ditambahkan jika diperlukan
|
||||
//console.log("Stopping session monitoring...");
|
||||
};
|
||||
|
||||
// Role checking functions
|
||||
const hasRole = (role: string): boolean => {
|
||||
return userRoles.value.includes(role);
|
||||
};
|
||||
|
||||
const hasClientRole = (role: string): boolean => {
|
||||
return clientRoles.value.includes(role);
|
||||
};
|
||||
|
||||
const hasAnyRole = (roles: string[]): boolean => {
|
||||
return roles.some((role) => userRoles.value.includes(role));
|
||||
};
|
||||
|
||||
const isAdmin = computed(
|
||||
() => hasRole("admin") || hasRole("super_admin") || hasRole("realm-admin")
|
||||
);
|
||||
|
||||
// Login function
|
||||
const login = async (provider = "keycloak") => {
|
||||
try {
|
||||
const currentUrl = window.location.pathname;
|
||||
const callbackUrl = currentUrl !== "/auth/login" ? currentUrl : "/";
|
||||
|
||||
const result = await signIn(provider, {
|
||||
callbackUrl,
|
||||
redirect: true
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// **PERBAIKAN: Update activity setelah login berhasil**
|
||||
updateActivity();
|
||||
await getSession();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced Logout function dengan reason support
|
||||
const logout = async (options?: {
|
||||
redirect?: string;
|
||||
clearStorage?: boolean;
|
||||
confirmDialog?: boolean;
|
||||
reason?: "manual" | "expired" | "admin" | "idle";
|
||||
}) => {
|
||||
const defaultOptions = {
|
||||
redirect: "/auth/login",
|
||||
clearStorage: true,
|
||||
confirmDialog: false,
|
||||
reason: "idle" as const,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
//console.log("Logout called with options:", defaultOptions);
|
||||
|
||||
// Menghilangkan pemanggilan fullLogout manual sesuai permintaan
|
||||
|
||||
if (defaultOptions.clearStorage && process.client) {
|
||||
//console.log("Clearing localStorage and sessionStorage");
|
||||
localStorage.removeItem("user-preferences");
|
||||
localStorage.removeItem("app-state");
|
||||
sessionStorage.clear();
|
||||
}
|
||||
|
||||
// Memanggil API logout server untuk menghandle logout Keycloak dan pembersihan session
|
||||
if (process.client) {
|
||||
// Mengambil keycloakIssuer dan redirectUri dari public runtime config
|
||||
const config = useRuntimeConfig();
|
||||
const keycloakIssuer = config.public.keycloakIssuer;
|
||||
const origin = window.location.origin;
|
||||
const redirectUri = `${origin}/auth/login`;
|
||||
|
||||
let logoutUrl = `${keycloakIssuer}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURIComponent(
|
||||
redirectUri
|
||||
)}`;
|
||||
if (idToken.value) {
|
||||
logoutUrl += `&id_token_hint=${encodeURIComponent(idToken.value)}`;
|
||||
}
|
||||
|
||||
//console.log("Calling Keycloak logout URL:", logoutUrl);
|
||||
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ id_token_hint: idToken.value })
|
||||
});
|
||||
|
||||
// Bersihkan local storage setelah logout server
|
||||
//console.log("Clearing localStorage and sessionStorage after server logout");
|
||||
localStorage.removeItem("user-preferences");
|
||||
localStorage.removeItem("app-state");
|
||||
sessionStorage.clear();
|
||||
}
|
||||
|
||||
await signOut({
|
||||
callbackUrl: `${defaultOptions.redirect}?reason=idle`,
|
||||
redirect: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
throw new Error("Logout failed. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
// Perbaikan fullLogout - simplified dan menggunakan logout yang sudah ada
|
||||
const fullLogout = async () => {
|
||||
try {
|
||||
console.log("Full logout initiated");
|
||||
|
||||
// 1. Stop session monitoring terlebih dahulu
|
||||
stopSessionMonitoring();
|
||||
console.log("Session monitoring stopped");
|
||||
console.log(process);
|
||||
|
||||
if (process.client) {
|
||||
// 2. Clear all storage before logout
|
||||
console.log("Clearing all storage before logout");
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem("user-preferences");
|
||||
localStorage.removeItem("app-state");
|
||||
localStorage.clear();
|
||||
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
|
||||
// Log cookies and sessionStorage before clearing
|
||||
console.log("Cookies before clearing:", document.cookie);
|
||||
console.log(
|
||||
"SessionStorage before clearing:",
|
||||
JSON.stringify(sessionStorage)
|
||||
);
|
||||
|
||||
// Clear cookies if any
|
||||
const cookies = document.cookie.split(";");
|
||||
const domain = window.location.hostname;
|
||||
cookies.forEach((c, index) => {
|
||||
const eqPos = c.indexOf("=");
|
||||
const name = eqPos > -1 ? c.substr(0, eqPos).trim() : c.trim();
|
||||
|
||||
console.log(`Clearing cookie [${index}]:`, name);
|
||||
|
||||
// Clear cookie for current path
|
||||
document.cookie =
|
||||
name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
|
||||
|
||||
// Clear cookie for current path and domain
|
||||
document.cookie =
|
||||
name +
|
||||
`=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${domain}`;
|
||||
|
||||
// Clear cookie for current path and domain with leading dot (for subdomains)
|
||||
if (domain.indexOf(".") !== -1) {
|
||||
const domainWithDot = "." + domain;
|
||||
document.cookie =
|
||||
name +
|
||||
`=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${domainWithDot}`;
|
||||
}
|
||||
|
||||
// Explicitly clear Keycloak cookies if present
|
||||
const keycloakCookies = [
|
||||
"KEYCLOAK_SESSION",
|
||||
"KEYCLOAK_IDENTITY",
|
||||
"KEYCLOAK_IDENTITY_LEGACY",
|
||||
"KEYCLOAK_REMEMBER_ME",
|
||||
"KEYCLOAK_REMEMBER_ME_LEGACY"
|
||||
];
|
||||
if (keycloakCookies.includes(name)) {
|
||||
console.log(`Explicitly clearing Keycloak cookie: ${name}`);
|
||||
document.cookie =
|
||||
name +
|
||||
`=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${domain}`;
|
||||
if (domain.indexOf(".") !== -1) {
|
||||
const domainWithDot = "." + domain;
|
||||
document.cookie =
|
||||
name +
|
||||
`=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${domainWithDot}`;
|
||||
}
|
||||
}
|
||||
console.log("Cleared cookie:", name);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Call server API to get the correct logout URL
|
||||
const response = await $fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token_hint: idToken.value
|
||||
})
|
||||
});
|
||||
|
||||
if (response.success && response.logoutUrl && process.client) {
|
||||
console.log("Redirecting to Keycloak logout:", response.logoutUrl);
|
||||
localStorage.removeItem("user-preferences");
|
||||
localStorage.removeItem("app-state");
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Append query parameters to logoutUrl
|
||||
let logoutUrlWithParams = response.logoutUrl;
|
||||
// 4. Redirect directly to Keycloak logout endpoint with appended params
|
||||
window.location.href = logoutUrlWithParams;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to signOut if server API fails
|
||||
|
||||
// Clear local data and update status to logged out
|
||||
if (process.client) {
|
||||
// Clear localStorage and sessionStorage again to be sure
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Reset data and status to reflect logged out state
|
||||
internalData.value = null;
|
||||
internalStatus.value = "unauthenticated";
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("Full logout failed:", error);
|
||||
// Emergency fallback - manual logout to Keycloak
|
||||
if (process.client) {
|
||||
try {
|
||||
console.log("Executing emergency logout");
|
||||
const config = useRuntimeConfig();
|
||||
const keycloakIssuer = config.public.keycloakIssuer;
|
||||
const origin = window.location.origin;
|
||||
const redirectUri = `${origin}/auth/login?reason=emergency`;
|
||||
// Clear all storage again to ensure
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
// Build logout URL with client_id as fallback
|
||||
let emergencyLogoutUrl = `${keycloakIssuer}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURIComponent(
|
||||
redirectUri
|
||||
)}`;
|
||||
// Add id_token_hint if available
|
||||
if (idToken.value) {
|
||||
emergencyLogoutUrl += `&id_token_hint=${encodeURIComponent(
|
||||
idToken.value
|
||||
)}`;
|
||||
} else {
|
||||
// Use client_id as alternative if id_token_hint is missing
|
||||
const clientId = config.public.keycloakClientId || "your-client-id";
|
||||
emergencyLogoutUrl += `&client_id=${encodeURIComponent(
|
||||
String(clientId)
|
||||
)}`;
|
||||
}
|
||||
console.log("Emergency logout URL:", emergencyLogoutUrl);
|
||||
window.location.href = emergencyLogoutUrl;
|
||||
} catch (emergencyError) {
|
||||
console.error("Emergency logout failed:", emergencyError);
|
||||
// Last resort - force redirect and clear storage
|
||||
if (process.client) {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
window.location.href = "/auth/login?reason=force";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Force refresh session
|
||||
const forceRefreshSession = async () => {
|
||||
try {
|
||||
await getSession();
|
||||
} catch (error) {
|
||||
console.error("Force refresh session error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// New method to check if Keycloak session is active
|
||||
const isKeycloakSessionActive = async (): Promise<boolean> => {
|
||||
if (!process.client) return false;
|
||||
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (
|
||||
session &&
|
||||
session.expires &&
|
||||
new Date(session.expires) > new Date()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking Keycloak session active status:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add computed property for accessTokenPayload
|
||||
// **PERBAIKAN: Return semua fungsi yang diperlukan termasuk session monitoring**
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
userRoles,
|
||||
clientRoles,
|
||||
accessToken,
|
||||
sessionExpires,
|
||||
isIdle: readonly(isIdle),
|
||||
|
||||
// Raw data untuk debugging
|
||||
data: internalData,
|
||||
status: internalStatus,
|
||||
|
||||
// Session data dari [...].ts
|
||||
idToken,
|
||||
refreshToken,
|
||||
accessTokenPayload,
|
||||
expiresAt,
|
||||
sessionScope,
|
||||
jwtToken,
|
||||
|
||||
// Role checking functions
|
||||
hasRole,
|
||||
hasClientRole,
|
||||
hasAnyRole,
|
||||
isAdmin,
|
||||
|
||||
// Session monitoring functions
|
||||
updateActivity,
|
||||
startSessionMonitoring,
|
||||
stopSessionMonitoring,
|
||||
|
||||
// Authentication actions
|
||||
login,
|
||||
logout,
|
||||
fullLogout,
|
||||
refresh: async () => {
|
||||
await getSession();
|
||||
},
|
||||
forceRefreshSession,
|
||||
isKeycloakSessionActive
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Composable untuk menyediakan fungsi dan aturan validasi umum.
|
||||
*/
|
||||
export const useValidation = () => {
|
||||
|
||||
const required = (value, message = 'Bidang ini wajib diisi.') => {
|
||||
// Memastikan nilai ada dan bukan string kosong (setelah trim)
|
||||
return (value !== null && value !== undefined && String(value).trim() !== '') || message;
|
||||
};
|
||||
|
||||
const minNameLength = (value, minLength = 3) => {
|
||||
if (!value) return true; // Lewati jika kosong (handle oleh 'required')
|
||||
const length = value.trim().length; // Hitung panjang setelah menghapus spasi di awal/akhir
|
||||
return length >= minLength || `Panjang minimal adalah ${minLength} karakter.`;
|
||||
};
|
||||
|
||||
const isNumber = (value) => {
|
||||
if (!value) return true; // Lewati jika kosong (handle oleh 'required')
|
||||
return /^[0-9]+$/.test(value) || 'Hanya boleh mengandung angka.';
|
||||
};
|
||||
|
||||
const phoneLength = (value) => {
|
||||
if (!value) return true;
|
||||
// Hapus karakter non-angka sebelum menghitung panjang
|
||||
const length = value.replace(/[^0-9]/g, '').length;
|
||||
return (length >= 9 && length <= 15) || 'Nomor telepon harus antara 9 hingga 15 digit.';
|
||||
};
|
||||
|
||||
const indonesianPhoneFormat = (value) => {
|
||||
if (!value) return true;
|
||||
// Pola: Harus dimulai dengan '08', diikuti 7 hingga 13 digit angka
|
||||
return /^08[0-9]{7,13}$/.test(value) || 'Format nomor telepon tidak valid (Contoh: 08xxxxxxxx).';
|
||||
};
|
||||
|
||||
// Gabungkan semua aturan validasi telepon ke dalam satu objek untuk kemudahan
|
||||
const phoneRules = [
|
||||
required,
|
||||
isNumber,
|
||||
phoneLength,
|
||||
indonesianPhoneFormat // Opsional: Hapus komentar jika format 08 wajib
|
||||
];
|
||||
|
||||
const textRules = [
|
||||
required,
|
||||
minNameLength
|
||||
];
|
||||
|
||||
const requiredRules = [
|
||||
required
|
||||
]
|
||||
|
||||
return {
|
||||
required,
|
||||
isNumber,
|
||||
phoneLength,
|
||||
indonesianPhoneFormat,
|
||||
phoneRules,
|
||||
textRules,
|
||||
requiredRules
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user