diff --git a/assets/scss/main.scss b/assets/scss/main.scss index 06cef8d..f8bc01c 100644 --- a/assets/scss/main.scss +++ b/assets/scss/main.scss @@ -2,6 +2,7 @@ @use "./variables" as *; @use "./colors" as *; @use "./typography" as *; +@use "./pages/dashboard" as *; // Global styles *, diff --git a/assets/scss/pages/_dashboard.scss b/assets/scss/pages/_dashboard.scss new file mode 100644 index 0000000..664f55f --- /dev/null +++ b/assets/scss/pages/_dashboard.scss @@ -0,0 +1,179 @@ +@use "../variables" as *; +@use "../colors" as *; + +.dashboard-container { + min-height: 100vh; + background-color: #f5f5f5; + padding: 0; +} + +.dashboard-content { + width: 100%; + margin: 0; + background: white; + min-height: 100vh; +} + +.dashboard-header { + background: white; + border-bottom: 1px solid #e0e0e0; + padding: 1.5rem 2rem; + + h1 { + font-size: 1.25rem; + font-weight: $font-weight-medium; + color: var(--color-neutral-900); + margin: 0; + } +} + +.dashboard-body { + padding: 2rem; +} + +.section { + margin-bottom: 2.5rem; + + &-title { + font-size: 0.875rem; + font-weight: $font-weight-semibold; + color: var(--color-neutral-900); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e0e0e0; + } +} + +.info-grid { + display: grid; + grid-template-columns: 140px 1fr; + gap: 0.75rem 1.5rem; + font-size: 0.875rem; + + .label { + color: var(--color-neutral-700); + font-weight: $font-weight-regular; + } + + .value { + color: var(--color-neutral-900); + font-weight: $font-weight-regular; + } +} + +.token-section { + margin-bottom: 1.5rem; + + .token-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + h4 { + font-size: 0.875rem; + font-weight: $font-weight-medium; + color: var(--color-neutral-900); + margin: 0; + } + } + + .token-content { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 1rem; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + line-height: 1.6; + color: var(--color-neutral-800); + word-break: break-all; + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + + &.collapsed { + max-height: 60px; + overflow: hidden; + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 30px; + background: linear-gradient(transparent, #f8f9fa); + } + } + } + + .toggle-btn { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--color-primary-600); + cursor: pointer; + background: none; + border: none; + padding: 0; + text-decoration: underline; + + &:hover { + color: var(--color-primary-700); + } + } +} + +.copy-btn { + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + background: white; + border: 1px solid #d0d0d0; + border-radius: 4px; + color: var(--color-neutral-700); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #f5f5f5; + border-color: #b0b0b0; + } + + .v-icon { + font-size: 14px; + margin-right: 4px; + } +} + +.payload-section { + .payload-content { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 1rem; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + line-height: 1.6; + color: var(--color-neutral-800); + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + } +} + +@media (max-width: $breakpoint-mobile) { + .dashboard-body { + padding: 1rem; + } + + .info-grid { + grid-template-columns: 1fr; + gap: 0.5rem; + + .label { + font-weight: $font-weight-semibold; + } + } +} diff --git a/middleware/auth.ts b/middleware/auth.ts index 44cb9e7..c580d24 100644 --- a/middleware/auth.ts +++ b/middleware/auth.ts @@ -10,9 +10,8 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => 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 + return; } } @@ -28,81 +27,61 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => return; } - // On server-side during development, skip intensive checks - // The cookie check will happen on client-side - if (process.server && process.env.NODE_ENV === 'development') { - if (to.query.authenticated === 'true') { - console.log('โญ๏ธ Server-side: Allowing authenticated redirect to pass through (dev mode)'); - return; // Allow server-side render to proceed, client will verify - } - console.log('โญ๏ธ Skipping intensive check on server-side during development'); - useAuth(); + // On server-side, skip auth check - let client handle it + if (process.server) { + console.log('โญ๏ธ Server-side: Skipping auth check (will verify on client)'); return; } + // CLIENT-SIDE ONLY from here + // Check for the authentication signal from a successful login redirect - const isAuthRedirect: boolean = to.query.authenticated === 'true'; + const isAuthRedirect = to.query.authenticated === 'true'; if (isAuthRedirect) { - console.log('โณ Client-side is processing a new login session, checking cookie...'); + console.log('โณ Processing new login session...'); - // Give the browser a moment to process the cookie from the redirect - if (process.client) { - await new Promise(resolve => setTimeout(resolve, 50)); - } + // Give browser time to process the cookie from redirect + await new Promise(resolve => setTimeout(resolve, 100)); - // Check authentication first before removing query parameter try { const { checkAuth } = useAuth(); console.log('๐Ÿ” Checking authentication after redirect...'); let user = await checkAuth(); - // If not found, retry once more - if (!user && process.client) { - console.log('โš ๏ธ Cookie not available yet, retrying...'); - await new Promise(resolve => setTimeout(resolve, 150)); + // Retry up to 3 times if cookie not available yet + let retries = 0; + while (!user && retries < 3) { + console.log(`โš ๏ธ Cookie not available yet, retry ${retries + 1}/3...`); + await new Promise(resolve => setTimeout(resolve, 200)); user = await checkAuth(); + retries++; } if (user) { - console.log('โœ… User is authenticated after redirect:', user.name || user.preferred_username || user.email); + console.log('โœ… User authenticated after redirect:', user.name || user.email); // Remove query parameter and allow access - await navigateTo({ path: to.path, query: {} }, { replace: true }); - return; // Allow access + return navigateTo({ path: to.path, query: {} }, { replace: true }); } else { - console.log('โŒ Still no session after retry, redirecting to login'); + console.log('โŒ No session after retries, redirecting to login'); return navigateTo('/LoginPage'); } } catch (authError) { console.error('โŒ Auth check failed after redirect:', authError); - // Retry once - if (process.client) { - await new Promise(resolve => setTimeout(resolve, 150)); - try { - const { checkAuth } = useAuth(); - const retryUser = await checkAuth(); - if (retryUser) { - console.log('โœ… User authenticated on retry after error'); - await navigateTo({ path: to.path, query: {} }, { replace: true }); - return; - } - } catch (retryError) { - console.error('โŒ Retry also failed:', retryError); - } - } return navigateTo('/LoginPage'); } } + // Normal auth check for protected routes try { const { checkAuth } = useAuth(); - console.log('๐Ÿ” Checking authentication status using useAuth...'); + console.log('๐Ÿ” Checking authentication status...'); const user = await checkAuth(); if (user) { - console.log('โœ… User is authenticated:', user.name || user.preferred_username || user.email); + console.log('โœ… User is authenticated:', user.name || user.email); return; } else { console.log('โŒ No valid session found, redirecting to login'); diff --git a/nuxt.config.ts b/nuxt.config.ts index 3bf7f25..3e6be5e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -72,6 +72,10 @@ export default defineNuxtConfig({ sessionDurationHours: parseInt(process.env.SESSION_DURATION_HOURS || '1', 10), oauthStateDurationMinutes: parseInt(process.env.OAUTH_STATE_DURATION_MINUTES || '10', 10), + // External API + externalApiBaseUrl: process.env.EXTERNAL_API_BASE_URL || 'http://10.10.150.100:8084', + externalApiTimeout: parseInt(process.env.EXTERNAL_API_TIMEOUT || '10000', 10), + public: { authUrl: process.env.AUTH_ORIGIN, // authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001", diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue index d11429c..5ba9bef 100644 --- a/pages/AdminKlinikRuang/[kodeKlinik].vue +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -791,7 +791,7 @@ diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index 8844ad2..22282cb 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -299,90 +299,74 @@ const { processNextQueue, } = useQueue("loket", loketId); -// WebSocket Configuration -const config = useRuntimeConfig(); -const wsBaseUrl = - config.public?.wsBaseUrl || "ws://10.10.150.100:8084/api/v1/ws"; -const uniqueSessionSuffix = ref(Math.random().toString(36).substring(7)); -let wsInstance = null; -const isConnected = ref(false); +// Generate a unique session suffix (random ID) +const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '') -const initWebSocket = () => { - if (!loketId.value) return null; +const anjunganClientId = computed(() => { + if (!loketId.value) return '' + return `admin-loket-${loketId.value}-${uniqueSessionSuffix.value}` +}) - console.log("๐Ÿ”Œ WebSocket (Admin): Connecting to", wsBaseUrl); +const fetchAllData = async () => { + if (!loketId.value) return; + console.log('๐Ÿ”„ AdminLoket refresh: Syncing data...'); + + try { + queueStore.ensureInitialData(); - wsInstance = useWebSocket({ - url: wsBaseUrl, - clientId: `admin-loket-${loketId.value}-${uniqueSessionSuffix.value}`, - onMessage: (data) => { - console.log("๐Ÿ“จ WebSocket message received (Admin):", data); + // 1. Ensure CLINIC data + const hasRegulerClinics = clinicStore.clinics.some( + (c) => c.jenisLayanan === "Reguler", + ); + if (!hasRegulerClinics) { + await clinicStore.fetchRegulerClinics(); + } - const messageData = data?.data || data; + // 2. Ensure LOKET data + const hasRegularLokets = loketStore.lokets.some((l) => l.id < 1000); + const targetId = parseInt(loketId.value); + const currentLoketExists = loketStore.getLoketById(targetId); - // If it's a message that should trigger a refresh for this loket - // For Admin, any message usually means we should refresh our list - fetchPatientsForCurrentLoket(); + if (!hasRegularLokets || !currentLoketExists) { + await loketStore.fetchLoketFromAPI(); + } - // Also potentially refresh quota - fetchQuotaFromAPI(); - }, - onOpen: () => { - console.log("โœ… WebSocket connected (Admin)"); - isConnected.value = true; - }, - onClose: () => { - isConnected.value = false; - }, - onError: () => { - isConnected.value = false; - }, - reconnectInterval: 5000, - maxReconnectAttempts: 10, - }); + // 3. Fresh room data + await ruangStore.fetchRuangFromAPI(); - return wsInstance; + // 4. Quota data + await fetchQuotaFromAPI(); + + // 5. Patient data + await fetchPatientsForCurrentLoket(); + + console.log('โœ… AdminLoket refresh: Success'); + } catch (err) { + console.error('โŒ AdminLoket refresh error:', err); + } }; +const isConnected = computed(() => queueStore.isWsConnected); + +// Watch for clientId changes and reconnect if needed +watch(anjunganClientId, (newClientId, oldClientId) => { + if (newClientId && newClientId !== oldClientId) { + console.log('๐Ÿ”„ Client ID changed, reconnecting centralized WebSocket...') + queueStore.initWebSocket(newClientId) + } +}) + // PERSISTENCE FIX: Ensure data exists on mount onMounted(async () => { + console.log("๐Ÿš€ AdminLoket Component mounted"); + + // Wait for stores to be hydrated and ready await nextTick(); - queueStore.ensureInitialData(); - // 1. Ensure CLINIC data is loaded first (critical for mapping codes like "25" -> "MT") - // Check if we need to fetch reguler clinics (e.g. if we only have local exec clinics) - const hasRegulerClinics = clinicStore.clinics.some( - (c) => c.jenisLayanan === "Reguler", - ); - if (!hasRegulerClinics) { - console.log("AdminLoket: Fetching reguler clinics..."); - await clinicStore.fetchRegulerClinics(); - } + // Initial fetch/sync + await fetchAllData(); - // 2. Ensure LOKET data is loaded next (needs clinic data for mapping) - // Fix: Don't just check length (which might include local exec data). Check for API data (id < 1000) - const hasRegularLokets = loketStore.lokets.some((l) => l.id < 1000); - const targetId = parseInt(loketId.value); - const currentLoketExists = loketStore.getLoketById(targetId); - - if (!hasRegularLokets || !currentLoketExists) { - console.log( - "AdminLoket: Fetching lokets (missing regular data or specific loket)...", - ); - await loketStore.fetchLoketFromAPI(); - } - - // 3. Fetch fresh room data from API (and de-duplicate) - await ruangStore.fetchRuangFromAPI(); - - // 4. Fetch specific quota/loket data from API to ensure fresh counts - // This matches MasterLoket.vue implementation - await fetchQuotaFromAPI(); - - // 5. Fetch patient data for this loket - await fetchPatientsForCurrentLoket(); - - // 6. Periodic check for daily reset (2 AM) + // Periodic check for daily reset (2 AM) resetCheckInterval = setInterval(() => { const didReset = queueStore.checkAndResetDaily(); if (didReset) { @@ -393,16 +377,22 @@ onMounted(async () => { } }, 60000); // Check every minute - // 7. Initialize and connect WebSocket - wsInstance = initWebSocket(); - if (wsInstance) { - wsInstance.connect(); - } + // Polling every 10 seconds to keep data synchronized as fallback + const pollingInterval = setInterval(fetchAllData, 10000); + + // Initialize and connect WebSocket (Centralized) + queueStore.initWebSocket(anjunganClientId.value); + + onUnmounted(() => { + clearInterval(pollingInterval); + }); }); onUnmounted(() => { if (resetCheckInterval) clearInterval(resetCheckInterval); - if (wsInstance) wsInstance.disconnect(); + // WebSocket is now global, we might not want to disconnect on every unmount + // if other pages are still open, but usually for Admin/Anjungan it's okay. + // We keep it connected unless specified otherwise. }); const apiQuota = ref(null); diff --git a/pages/Anjungan/Anjungan/[id].vue b/pages/Anjungan/Anjungan/[id].vue index 5cba8f3..08534e3 100644 --- a/pages/Anjungan/Anjungan/[id].vue +++ b/pages/Anjungan/Anjungan/[id].vue @@ -722,53 +722,81 @@ const queueStore = useQueueStore(); const doctorStore = useDoctorStore(); const { printTicketFromPatient, isPrinting } = useThermalPrint(); -// WebSocket state -const config = useRuntimeConfig(); -const wsBaseUrl = - config.public?.wsBaseUrl || "ws://10.10.150.100:8084/api/v1/ws"; -const uniqueSessionSuffix = ref(Math.random().toString(36).substring(7)); -let wsInstance = null; -const isConnected = ref(false); - -const initWebSocket = () => { - if (!anjunganId.value) return null; - - console.log("๐Ÿ”Œ WebSocket (Anjungan): Connecting to", wsBaseUrl); - - wsInstance = useWebSocket({ - url: wsBaseUrl, - clientId: `anjungan-${anjunganId.value}-${uniqueSessionSuffix.value}`, - onMessage: (data) => { - console.log("๐Ÿ“จ WebSocket message received (Anjungan):", data); - // In Anjungan, we might need to refresh clinics or stats - // but usually Anjungan is more focused on registration. - // We can refresh clinics just in case. - if (anjunganData.value?.jenisPasien === "Reguler") { - clinicStore.fetchRegulerClinics(); - } - }, - onOpen: () => { - console.log("โœ… WebSocket connected (Anjungan)"); - isConnected.value = true; - }, - onClose: () => { - isConnected.value = false; - }, - onError: () => { - isConnected.value = false; - }, - reconnectInterval: 5000, - maxReconnectAttempts: 10, - }); - - return wsInstance; -}; - -// Gunakan storeToRefs untuk memastikan reaktivitas yang stabil terutama saat cold start/refresh +// reactive refs from stores const { anjunganItems } = storeToRefs(anjunganStore); const { getAllClinics } = storeToRefs(clinicStore); const { doctorsByKlinikId, loadingDoctors } = storeToRefs(doctorStore); +const anjunganId = computed(() => { + const raw = route.params.id; + const val = Array.isArray(raw) ? raw[0] : raw; + const parsed = parseInt(val, 10); + return isNaN(parsed) ? null : parsed; +}); + +const anjunganData = computed(() => { + const idValue = anjunganId.value; + if (!idValue) return null; + + // Use the refs from storeToRefs + const list = anjunganItems.value || []; + return list.find((a) => Number(a.id) === Number(idValue)) || null; +}); + +// Generate a unique session suffix (random ID) +const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '') + +const anjunganClientId = computed(() => { + if (!anjunganId.value) return '' + return `anjungan-${anjunganId.value}-${uniqueSessionSuffix.value}` +}) + +const fetchAllData = async () => { + if (!anjunganId.value) return; + console.log('๐Ÿ”„ Anjungan refresh: Fetching clinics and initial data...'); + try { + if (anjunganData.value?.jenisPasien === "Reguler") { + await clinicStore.fetchRegulerClinics(); + } + queueStore.ensureInitialData(); + console.log('โœ… Anjungan refresh: Success'); + } catch (err) { + console.error('โŒ Anjungan refresh error:', err); + } +}; + +const isConnected = computed(() => queueStore.isWsConnected); + +// Watch for clientId changes and reconnect if needed +watch(anjunganClientId, (newClientId, oldClientId) => { + if (newClientId && newClientId !== oldClientId) { + console.log('๐Ÿ”„ Client ID changed, reconnecting centralized WebSocket...') + queueStore.initWebSocket(newClientId) + } +}) + +onMounted(async () => { + console.log("๐Ÿš€ Anjungan Component mounted"); + + // Wait for stores to be hydrated and ready + await nextTick(); + + // Initial fetch + await fetchAllData(); + + // Polling every 10 seconds to keep data synchronized as fallback + const pollingInterval = setInterval(fetchAllData, 10000); + + // Use centralized WebSocket + queueStore.initWebSocket(anjunganClientId.value); + + onUnmounted(() => { + clearInterval(pollingInterval); + }); +}); + +// Lifecycle management consolidated below + // Validasi bahwa queueStore ter-load dengan benar onMounted(() => { console.log("๐Ÿ” QueueStore initialized:", queueStore); @@ -782,21 +810,7 @@ onMounted(() => { } }); -const anjunganId = computed(() => { - const raw = route.params.id; - const val = Array.isArray(raw) ? raw[0] : raw; - const parsed = parseInt(val, 10); - return isNaN(parsed) ? null : parsed; -}); - -const anjunganData = computed(() => { - const idValue = anjunganId.value; - if (!idValue) return null; - - // Langsung cari di anjunganItems yang sudah di-storeToRefs - const list = anjunganItems.value || []; - return list.find((a) => Number(a.id) === Number(idValue)) || null; -}); +// Initial data synchronization handled in watcher and onMounted // doctorsByKlinikId and loadingDoctors are now from doctorStore @@ -855,24 +869,15 @@ const dialogDoctors = computed(() => { return staticDoctors; }); -onMounted(async () => { - console.log("๐Ÿš€ Component mounted"); - await nextTick(); - console.log("โœ… Initial mount complete"); - - // Initialize and connect WebSocket - wsInstance = initWebSocket(); - if (wsInstance) { - wsInstance.connect(); - } -}); - +// Cleanup saat component unmount (Preserved old code in comments) +/* import { onUnmounted } from "vue"; onUnmounted(() => { if (wsInstance) { wsInstance.disconnect(); } }); +*/ const filteredClinics = computed(() => { if (!anjunganData.value || !anjunganData.value.klinik) return []; diff --git a/pages/Anjungan/AntreanMasuk/[id].vue b/pages/Anjungan/AntreanMasuk/[id].vue index b0832d4..7a56f28 100644 --- a/pages/Anjungan/AntreanMasuk/[id].vue +++ b/pages/Anjungan/AntreanMasuk/[id].vue @@ -501,10 +501,13 @@ const anjunganClientId = computed(() => { return `anjungan-masuk-${screenId.value}-${uniqueSessionSuffix.value}` }) -let wsInstance = null -const isConnected = ref(false) +// let wsInstance = null +// const isConnected = ref(false) -const initWebSocket = () => { +// WebSocket configuration (Centralized in queueStore) +// Old implementation preserved for rollback +/* +const initWebSocketLocal = () => { if (!screenId.value) return null console.log('๐Ÿ”Œ WebSocket: Connecting to', wsBaseUrl) @@ -516,7 +519,6 @@ const initWebSocket = () => { fallbackPostUrl: '/stats-api/ws', onMessage: (data) => { console.log('๐Ÿ“จ WebSocket message received:', data) - // Real-time update: refetch all relevant data when any message arrives fetchAllData() }, onOpen: () => { @@ -535,6 +537,9 @@ const initWebSocket = () => { return wsInstance } +*/ + +const isConnected = computed(() => queueStore.isWsConnected); // Watch loketPatients to ensure reactivity after refresh watch(loketPatients, (newPatients) => { @@ -596,17 +601,12 @@ onMounted(() => { // Polling every 10 seconds to keep data synchronized as fallback const pollingInterval = setInterval(fetchAllData, 10000); - // Initialize and connect WebSocket - wsInstance = initWebSocket() - if (wsInstance) { - wsInstance.connect() - } + // Initialize and connect WebSocket (Centralized) + queueStore.initWebSocket(anjunganClientId.value); onUnmounted(() => { clearInterval(pollingInterval); - if (wsInstance) { - wsInstance.disconnect() - } + // wsInstance is now global }); updateTime() diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index 6b56137..02be5af 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -441,6 +441,22 @@ const updateTime = () => { const config = useRuntimeConfig() const wsBaseUrl = config.public?.wsBaseUrl || 'ws://10.10.150.100:8084/api/v1/ws' +// Generate a unique session suffix (random ID) +const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '') + +const fetchAllData = async () => { + if (!kodeKlinik.value) return; + console.log('๐Ÿ”„ AntrianKlinikRuang refresh: Fetching data...'); + try { + await clinicStore.fetchRegulerClinics(); + await ruangStore.fetchRuangFromAPI(); + queueStore.ensureInitialData(); + console.log('โœ… AntrianKlinikRuang refresh: Success'); + } catch (err) { + console.error('โŒ AntrianKlinikRuang refresh error:', err); + } +}; + // WebSocket client ID for anjungan (you can make this dynamic based on screen number) const anjunganClientId = computed(() => { if (!kodeKlinik.value) return '' @@ -465,17 +481,19 @@ const anjunganClientId = computed(() => { } // Priority 3: Fallback to client ID without screen number (broadcast) - const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}` - console.log('๐Ÿ†” Using default client ID (no screen number):', clientId) + const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}-${uniqueSessionSuffix.value}` + console.log('๐Ÿ†” Using default client ID (tab-specific):', clientId) return clientId }) // WebSocket instance -let wsInstance = null -const isConnected = ref(false) +// let wsInstance = null +// const isConnected = ref(false) -// Initialize WebSocket connection -const initWebSocket = () => { +// WebSocket configuration (Centralized in queueStore) +// Old implementation preserved for rollback +/* +const initWebSocketLocal = () => { if (!kodeKlinik.value || !anjunganClientId.value) { console.warn('Cannot initialize WebSocket: missing kodeKlinik or clientId') return null @@ -491,114 +509,65 @@ const initWebSocket = () => { fallbackPostUrl: '/stats-api/ws', onMessage: (data) => { console.log('๐Ÿ“จ WebSocket raw message received:', data) - console.log('๐Ÿ“จ Message type:', typeof data) - - // Backend mengirim data langsung atau dalam wrapper - // Handle both cases let messageData = data - - // Jika data ada dalam property 'data', ambil itu if (data && typeof data === 'object' && data.data) { messageData = data.data - console.log('๐Ÿ“ฆ Extracted data from wrapper:', messageData) } - - // Handle patient called message - format: { noantrian: "AA001", tipeLayanan: "Pemeriksaan Awal" } if (messageData && messageData.noantrian) { const nomorAntrian = messageData.noantrian - const tipeLayanan = messageData.tipeLayanan || null // "Pemeriksaan Awal" atau "Tindakan" - console.log('โœ… Processing patient call for nomor antrian:', nomorAntrian, 'tipeLayanan:', tipeLayanan) - - // Find patient by nomor antrian (check both noAntrian and barcode) + const tipeLayanan = messageData.tipeLayanan || null const patientIndex = queueStore.allPatients.findIndex(p => { const pNoAntrian = p.noAntrian?.split(" |")[0] || p.noAntrian return (pNoAntrian === nomorAntrian || p.barcode === nomorAntrian) && p.kodeKlinik === kodeKlinik.value && p.processStage === 'klinik-ruang' }) - if (patientIndex !== -1) { - // Update patient status to 'di-loket' (called) dan update tipeLayanan serta tracking flags - const oldPatient = { ...queueStore.allPatients[patientIndex] } const updateData = { ...queueStore.allPatients[patientIndex], status: 'di-loket', lastCalledAt: new Date().toISOString(), lastCalledTipeLayanan: tipeLayanan, } - - // Update tipeLayanan agar pasien muncul di kolom yang sesuai if (tipeLayanan) { updateData.tipeLayanan = tipeLayanan } - - // Update tracking flags berdasarkan tipeLayanan if (tipeLayanan === 'Pemeriksaan Awal') { updateData.calledPemeriksaanAwal = true } else if (tipeLayanan === 'Tindakan') { updateData.calledTindakan = true } - queueStore.allPatients[patientIndex] = updateData - - console.log('โœ… Patient data updated in store based on nomor antrian:', nomorAntrian) - console.log('๐Ÿ“Š Old:', { status: oldPatient.status, tipeLayanan: oldPatient.tipeLayanan }) - console.log('๐Ÿ“Š New:', { - status: updateData.status, - tipeLayanan: updateData.tipeLayanan, - calledPemeriksaanAwal: updateData.calledPemeriksaanAwal, - calledTindakan: updateData.calledTindakan - }) - } else { - // If patient doesn't exist, log warning - console.warn('โš ๏ธ Patient not found in store with nomor antrian:', nomorAntrian) - console.log('๐Ÿ“‹ Available patients:', klinikPatients.value.map(p => ({ - noAntrian: p.noAntrian?.split(" |")[0], - barcode: p.barcode, - kodeKlinik: p.kodeKlinik - }))) } - } else { - console.log('โ„น๏ธ Message format is invalid or missing noantrian:', messageData) } }, onOpen: () => { - console.log('โœ… Anjungan WebSocket connected successfully!') - console.log('๐Ÿ†” Connected as:', anjunganClientId.value) isConnected.value = true }, onClose: () => { - console.log('โš ๏ธ Anjungan WebSocket closed') isConnected.value = false }, onError: (error) => { - console.error('โŒ Anjungan WebSocket error:', error) isConnected.value = false }, reconnectInterval: 3000, maxReconnectAttempts: 10, }) - // Watch connection status - if (wsInstance.isConnected) { - watch(wsInstance.isConnected, (connected) => { - isConnected.value = connected - }) - } - return wsInstance } +*/ + +const isConnected = computed(() => queueStore.isWsConnected); onMounted(async () => { console.log('๐Ÿš€ AntrianKlinikRuang (Anjungan) mounted, syncing data...') - // 1. Sync data if needed - try { - await clinicStore.fetchRegulerClinics() - await ruangStore.fetchRuangFromAPI() - } catch (error) { - console.error('โŒ Error syncing data in AntrianKlinikRuang:', error) - } + // Wait for stores to be hydrated and ready + await nextTick(); + + // Initial fetch/sync + await fetchAllData(); // Redirect to index if klinik not found after sync if (!klinikData.value) { @@ -609,34 +578,30 @@ onMounted(async () => { updateTime() timeInterval = setInterval(updateTime, 1000) + + // Polling every 10 seconds to keep data synchronized as fallback + const pollingInterval = setInterval(fetchAllData, 10000); - // Initialize and connect WebSocket + // Initialize and connect WebSocket (Centralized) if (kodeKlinik.value) { - wsInstance = initWebSocket() - if (wsInstance) { - wsInstance.connect() - console.log('๐Ÿš€ WebSocket connection initiated') - } + queueStore.initWebSocket(anjunganClientId.value); } + + onUnmounted(() => { + clearInterval(pollingInterval); + }); }) onUnmounted(() => { if (timeInterval) clearInterval(timeInterval) - if (wsInstance) { - wsInstance.disconnect() - console.log('๐Ÿ”Œ WebSocket disconnected') - } + // wsInstance is now global }) // Watch for clientId changes and reconnect if needed watch(anjunganClientId, (newClientId, oldClientId) => { - if (newClientId && newClientId !== oldClientId && wsInstance) { - console.log('๐Ÿ”„ Client ID changed, reconnecting...') - wsInstance.disconnect() - wsInstance = initWebSocket() - if (wsInstance) { - wsInstance.connect() - } + if (newClientId && newClientId !== oldClientId) { + console.log('๐Ÿ”„ Client ID changed, reconnecting centralized WebSocket...') + queueStore.initWebSocket(newClientId) } }) diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue index fd95447..dca9b6c 100644 --- a/pages/Anjungan/AntrianLoket/[id].vue +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -864,10 +864,39 @@ const anjunganClientId = computed(() => { return `anjungan-loket-${loketId.value}-${uniqueSessionSuffix.value}` }) -let wsInstance = null -const isConnected = ref(false) +const fetchAllData = async () => { + if (!loketId.value) return; + console.log('๐Ÿ”„ Anjungan refresh: Fetching data for current loket...'); + try { + // 1. Fetch data for this loket + await queueStore.fetchPatientsForLoket(loketId.value); + + // 2. Ensure initial data + queueStore.ensureInitialData(); + + console.log('โœ… Anjungan refresh: Success'); + } catch (err) { + console.error('โŒ Anjungan refresh error:', err); + } +}; -const initWebSocket = () => { +const isConnected = computed(() => queueStore.isWsConnected); + +// Watch for clientId changes and reconnect if needed +watch(anjunganClientId, (newClientId, oldClientId) => { + if (newClientId && newClientId !== oldClientId) { + console.log('๐Ÿ”„ Client ID changed, reconnecting centralized WebSocket...') + queueStore.initWebSocket(newClientId) + } +}) + +// let wsInstance = null +// const isConnected = ref(false) + +// WebSocket configuration (Centralized in queueStore) +// Old implementation preserved for rollback +/* +const initWebSocketLocal = () => { if (!loketId.value) return null console.log('๐Ÿ”Œ WebSocket: Connecting to', wsBaseUrl) @@ -879,25 +908,14 @@ const initWebSocket = () => { fallbackPostUrl: '/stats-api/ws', onMessage: (data) => { console.log('๐Ÿ“จ WebSocket message received:', data) - - // Handle both raw and wrapped data (matching AntreanMasuk style) const messageData = data?.data || data - - // If it's a patient call message, we should mimic the BroadcastChannel behavior - // The backend format might vary, but we look for 'noantrian' or 'patient' if (messageData?.noantrian || messageData?.type === 'CALL_PATIENT') { - console.log('๐Ÿ“ž WebSocket triggered call update'); - - // Refetch to ensure store is sync queueStore.fetchPatientsForLoket(loketId.value); - - // If message has full patient object, we can also update broadcastedPatient for instant Hero display if (messageData.patient) { broadcastedPatient.value = { ...messageData.patient, _rawMessage: messageData }; - const targetId = String(loketId.value); const key = targetId ? `loket-${targetId}` : 'loket'; if (queueStore.currentProcessingPatient) { @@ -905,7 +923,6 @@ const initWebSocket = () => { } } } else { - // General update: refetch current loket data queueStore.fetchPatientsForLoket(loketId.value); } }, @@ -925,8 +942,16 @@ const initWebSocket = () => { return wsInstance } +*/ + + + +onMounted(async () => { + console.log("๐Ÿš€ AntrianLoket Component mounted"); + + // Wait for stores to be hydrated and ready + await nextTick(); -onMounted(() => { // Fetch loket data from API to ensure fresh mapping loketStore.fetchLoketFromAPI(true).catch(console.error); @@ -967,22 +992,24 @@ onMounted(() => { console.error('โŒ BroadcastChannel error:', e); } - // Initialize and connect WebSocket - wsInstance = initWebSocket() - if (wsInstance) { - wsInstance.connect() - } + // Initial fetch + await fetchAllData(); - // Ensure initial data is loaded if store is empty - setTimeout(() => { - queueStore.ensureInitialData(); - }, 200); + // Polling every 10 seconds to keep data synchronized as fallback + const pollingInterval = setInterval(fetchAllData, 10000); + + // Initialize and connect WebSocket (Centralized) + queueStore.initWebSocket(anjunganClientId.value); + + onUnmounted(() => { + clearInterval(pollingInterval); + }); }); onUnmounted(() => { if (timeInterval) clearInterval(timeInterval) if (broadcastChannel) broadcastChannel.close() - if (wsInstance) wsInstance.disconnect() + // wsInstance is now global }) diff --git a/pages/CheckInPasien/checkIn.vue b/pages/CheckInPasien/checkIn.vue index 752fabf..2f956d6 100644 --- a/pages/CheckInPasien/checkIn.vue +++ b/pages/CheckInPasien/checkIn.vue @@ -1768,40 +1768,35 @@ const statusOptions = [ { title: "Belum Diizinkan", value: "NOT_ALLOWED" }, ]; -// WebSocket state -let wsInstance: any = null; -const isConnected = ref(false); -const uniqueSessionSuffix = ref(Math.random().toString(36).substring(7)); +// WebSocket stabilization - version 1.1 +// Generate a unique session suffix (random ID) +const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '') -const initWebSocket = () => { - console.log("๐Ÿ”Œ WebSocket (CheckIn): Connecting to", wsBaseUrl); +const checkInClientId = computed(() => { + return `checkin-${uniqueSessionSuffix.value}` +}) - wsInstance = useWebSocket({ - url: wsBaseUrl, - clientId: `checkin-${uniqueSessionSuffix.value}`, - onMessage: (data) => { - console.log("๐Ÿ“จ WebSocket message received (CheckIn):", data); - // General update: ensure store data is synchronized - // This will ensure new registrations from Anjungan are picked up - queueStore.ensureInitialData(); - }, - onOpen: () => { - console.log("โœ… WebSocket connected (CheckIn)"); - isConnected.value = true; - }, - onClose: () => { - isConnected.value = false; - }, - onError: () => { - isConnected.value = false; - }, - reconnectInterval: 5000, - maxReconnectAttempts: 10, - }); - - return wsInstance; +const fetchAllData = async () => { + console.log('๐Ÿ”„ CheckIn refresh: Syncing data...'); + try { + queueStore.ensureInitialData(); + checkAndResetDaily(); + console.log('โœ… CheckIn refresh: Success'); + } catch (err) { + console.error('โŒ CheckIn refresh error:', err); + } }; +const isConnected = computed(() => queueStore.isWsConnected); + +// Watch for clientId changes and reconnect if needed +watch(checkInClientId, (newClientId, oldClientId) => { + if (newClientId && newClientId !== oldClientId) { + console.log('๐Ÿ”„ Client ID changed, reconnecting centralized WebSocket...') + queueStore.initWebSocket(newClientId) + } +}) + // Get klinik list for dropdown const klinikOptions = computed(() => { const klinikList = masterStore.klinikList.map( @@ -2719,22 +2714,30 @@ onMounted(async () => { cameraChecking.value = false; } + // Wait for stores to be hydrated and ready + await nextTick(); + // Load history untuk ditampilkan di sidebar loadHistory(); - // Check and reset daily at 10 PM - checkAndResetDaily(); + // Initial fetch/sync + await fetchAllData(); - // Initialize WebSocket - wsInstance = initWebSocket(); - if (wsInstance) { - wsInstance.connect(); - } + // Polling every 10 seconds to keep data synchronized as fallback + const pollingInterval = setInterval(fetchAllData, 10000); + + // Initialize WebSocket (Centralized) + queueStore.initWebSocket(checkInClientId.value); // Set interval to check every minute for reset time - setInterval(() => { + const resetCheckInterval = setInterval(() => { checkAndResetDaily(); }, 60000); // Check every minute + + onUnmounted(() => { + clearInterval(pollingInterval); + clearInterval(resetCheckInterval); + }); } else { hasCamera.value = false; cameraChecking.value = false; @@ -2767,10 +2770,8 @@ onUnmounted(async () => { autoCloseTimer = null; } - // Disconnect WebSocket - if (wsInstance) { - wsInstance.disconnect(); - } + // WebSocket is now global, we might not want to disconnect here if other tabs use it. + // if (wsInstance) wsInstance.disconnect(); // Old logic // Reset state isScanning.value = false; diff --git a/pages/clear-session.vue b/pages/clear-session.vue new file mode 100644 index 0000000..6cf5440 --- /dev/null +++ b/pages/clear-session.vue @@ -0,0 +1,37 @@ + + + diff --git a/pages/index.vue b/pages/index.vue index af75733..6e4069b 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,16 +1,15 @@ - \ No newline at end of file + diff --git a/server/api/auth/clear-session.post.ts b/server/api/auth/clear-session.post.ts new file mode 100644 index 0000000..5ce80e1 --- /dev/null +++ b/server/api/auth/clear-session.post.ts @@ -0,0 +1,77 @@ +// server/api/auth/clear-session.post.ts +// Endpoint to forcefully clear session cookies and logout from Keycloak +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(); + console.log('๐Ÿงน Clear session endpoint called'); + + // Get the current session to retrieve ID token for Keycloak logout + const sessionCookie = getCookie(event, 'user_session'); + let idToken = null; + + if (sessionCookie) { + try { + // Try to decode JWT-based session from cookie + const sessionJson = Buffer.from(sessionCookie, 'base64').toString('utf-8'); + const session = JSON.parse(sessionJson); + idToken = session.idToken; + console.log('๐Ÿ”‘ ID token found for Keycloak logout'); + } catch (error) { + console.warn('โš ๏ธ Could not parse session cookie (might be old format)'); + // Continue anyway to clear cookies + } + } + + // Clear all auth-related cookies + console.log('๐Ÿงน Clearing all session cookies...'); + deleteCookie(event, 'user_session'); + deleteCookie(event, 'oauth_state'); + + // Also clear with different path variations + deleteCookie(event, 'user_session', { path: '/' }); + deleteCookie(event, 'oauth_state', { path: '/' }); + + console.log('โœ… Local session cleared successfully'); + + // Build Keycloak logout URL + const logoutPath = config.keycloakLogoutUri || `${config.keycloakIssuer}/protocol/openid-connect/logout`; + const logoutUrl = new URL(logoutPath); + + const postLogoutRedirectUri = config.postLogoutRedirectUri || `${config.public.authUrl}/LoginPage?logout=success`; + + logoutUrl.searchParams.set('client_id', config.keycloakClientId); + logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); + + // Add ID token hint if available for proper Keycloak session termination + if (idToken) { + logoutUrl.searchParams.set('id_token_hint', idToken); + console.log('๐Ÿ”‘ Added id_token_hint to Keycloak logout URL'); + } + + console.log('๐Ÿ”— Keycloak logout URL:', logoutUrl.toString()); + + return { + success: true, + logoutUrl: logoutUrl.toString(), + message: 'Session cleared successfully. Redirecting to Keycloak logout...' + }; + + } catch (error: any) { + console.error('โŒ Clear session error:', error); + + // Even on error, provide a basic logout URL + const config = useRuntimeConfig(); + const postLogoutRedirectUri = config.postLogoutRedirectUri || `${config.public.authUrl}/LoginPage?logout=success`; + const logoutPath = config.keycloakLogoutUri || `${config.keycloakIssuer}/protocol/openid-connect/logout`; + const fallbackLogoutUrl = new URL(logoutPath); + fallbackLogoutUrl.searchParams.set('client_id', config.keycloakClientId); + fallbackLogoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); + + return { + success: false, + logoutUrl: fallbackLogoutUrl.toString(), + error: 'Error during session cleanup', + message: error.message + }; + } +}); diff --git a/server/api/auth/keycloak-callback.get.ts b/server/api/auth/keycloak-callback.get.ts index c9afe11..f43bb94 100644 --- a/server/api/auth/keycloak-callback.get.ts +++ b/server/api/auth/keycloak-callback.get.ts @@ -1,242 +1,261 @@ const config = useRuntimeConfig(); // Define session duration (default to 1 hour if not specified in config) -const SESSION_DURATION = (config.sessionDurationHours || 1) * 60 * 60 * 24; +const SESSION_DURATION = (config.sessionDurationHours || 1) * 60 * 60 * 24; // This is the MAIN SESSION duration. It controls how long a user stays logged in. // Current configuration: 1 hour (3600 seconds). export default defineEventHandler(async (event) => { - try { - const config = useRuntimeConfig(); - const query = getQuery(event); + try { + const config = useRuntimeConfig(); + const query = getQuery(event); - console.log('๐Ÿ”„ === KEYCLOAK CALLBACK STARTED ==='); - console.log('๐Ÿ“‹ Query parameters:', query); + console.log("๐Ÿ”„ === KEYCLOAK CALLBACK STARTED ==="); + console.log("๐Ÿ“‹ Query parameters:", query); - const code = query.code as string; - const state = query.state as string; - const error = query.error as string; - const storedState = getCookie(event, 'oauth_state'); + const code = query.code as string; + const state = query.state as string; + const error = query.error as string; + const storedState = getCookie(event, "oauth_state"); - if (error) { - console.error('โŒ OAuth error from Keycloak:', error); - const errorDescription = query.error_description as string; - console.error('โŒ Error description:', errorDescription); + if (error) { + console.error("โŒ OAuth error from Keycloak:", error); + const errorDescription = query.error_description as string; + console.error("โŒ Error description:", errorDescription); - const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - console.log('๐Ÿ“ Code received:', !!code); - console.log('๐ŸŽฒ State from URL:', state); - console.log('๐ŸŽฒ State from cookie:', storedState); - console.log('๐ŸŽฒ State validation:', state === storedState); - - if (!state || state !== storedState) { - console.error('โŒ Invalid state parameter - possible CSRF attack'); - console.error(' Expected:', storedState); - console.error(' Received:', state); - - const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.'); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - deleteCookie(event, 'oauth_state'); - - if (!code) { - console.error('โŒ Authorization code not provided'); - const errorMsg = encodeURIComponent('No authorization code received from Keycloak.'); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - // Validate Keycloak configuration - if (!config.keycloakIssuer) { - console.error('โŒ KEYCLOAK_ISSUER is not configured'); - const errorMsg = encodeURIComponent('Keycloak server is not configured. Please contact administrator.'); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - if (!config.keycloakClientId || !config.keycloakClientSecret) { - console.error('โŒ Keycloak client credentials are not configured'); - const errorMsg = encodeURIComponent('Keycloak client credentials are missing. Please contact administrator.'); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`; - const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`; - - console.log('๐Ÿ”— Token URL:', tokenUrl); - console.log('๐Ÿ”— Redirect URI:', redirectUri); - console.log('๐Ÿ”‘ Client ID:', config.keycloakClientId ? '***configured***' : 'MISSING'); - - const tokenPayload = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: config.keycloakClientId, - client_secret: config.keycloakClientSecret, - code, - redirect_uri: redirectUri, - }); - - let tokenResponse; - try { - // Create abort controller for timeout (compatible with all Node.js versions) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout - - tokenResponse = await fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: tokenPayload, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - } catch (fetchError: any) { - console.error('โŒ Fetch error details:'); - console.error(' - Error type:', fetchError.name); - console.error(' - Error message:', fetchError.message); - console.error(' - Token URL attempted:', tokenUrl); - - // Provide more specific error messages - let errorMsg = 'Failed to connect to authentication server.'; - if (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) { - errorMsg = 'Authentication server timeout. Please try again.'; - } else if (fetchError.message.includes('ENOTFOUND') || fetchError.message.includes('getaddrinfo')) { - errorMsg = 'Cannot reach authentication server. Please check network connection.'; - } else if (fetchError.message.includes('ECONNREFUSED')) { - errorMsg = 'Authentication server refused connection. Server may be down.'; - } else if (fetchError.message.includes('certificate') || fetchError.message.includes('SSL')) { - errorMsg = 'SSL certificate error. Please contact administrator.'; - } - - const encodedError = encodeURIComponent(errorMsg); - return sendRedirect(event, `/LoginPage?error=${encodedError}`); - } - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - console.error('โŒ Token exchange failed:', errorText); - const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - const tokens = await tokenResponse.json(); - - let idTokenPayload; - try { - idTokenPayload = JSON.parse( - Buffer.from(tokens.id_token.split('.')[1], 'base64').toString() - ); - } catch (decodeError) { - console.error('โŒ Failed to decode ID token:', decodeError); - const errorMsg = encodeURIComponent('Invalid ID token format'); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); - } - - // Store minimal session data in cookie to reduce size - // The ID token contains user info, so we can decode it when needed - const sessionData = { - // Store only essential user info (can be decoded from ID token if needed) - user: { - id: idTokenPayload.sub, - email: idTokenPayload.email, - name: idTokenPayload.name || idTokenPayload.preferred_username, - preferred_username: idTokenPayload.preferred_username, - }, - // Store tokens - these are necessary for API calls - // Note: These JWT tokens are large, but necessary for authentication - accessToken: tokens.access_token, - idToken: tokens.id_token, - refreshToken: tokens.refresh_token, - // Session metadata - expiresAt: Date.now() + (SESSION_DURATION * 1000), - createdAt: Date.now(), - }; - - // Determine if we should use secure cookies - // For localhost, always use secure: false - const isSecure = process.env.NODE_ENV === 'production' && - event.node.req.headers['x-forwarded-proto'] === 'https'; - - console.log('๐Ÿ”— Setting session cookie with secure flag:', isSecure); - console.log('โฑ๏ธ Session duration:', SESSION_DURATION, 'seconds'); - console.log('๐ŸŒ Request host:', event.node.req.headers.host); - console.log('๐Ÿ”’ Protocol:', event.node.req.headers['x-forwarded-proto'] || 'http'); - - // Set cookie with proper settings for localhost - // For localhost HTTP, we need secure: false and sameSite: 'lax' - // IMPORTANT: Ensure domain is not set for localhost (allows cookie to work) - const cookieOptions: any = { - httpOnly: true, - secure: isSecure, - sameSite: 'lax' as const, - maxAge: SESSION_DURATION, - path: '/', - // Explicitly don't set domain for localhost - this is important! - // Setting domain to 'localhost' can cause cookies to not work - }; - - // For localhost, don't set domain (allows cookie to work on localhost) - // Only set domain in production if needed - if (process.env.NODE_ENV === 'production' && !event.node.req.headers.host?.includes('localhost')) { - // Optionally set domain in production - // cookieOptions.domain = '.yourdomain.com'; - } - - // Store session in server-side store and use session ID in cookie - // This avoids cookie size limits (4KB) - const { createSession } = await import('~/server/utils/sessionStore'); - const sessionId = createSession(sessionData); - - console.log('๐Ÿ’พ Session stored server-side with ID:', sessionId.substring(0, 8) + '...'); - console.log('๐Ÿ“ฆ Session ID cookie size: ~64 bytes (much smaller!)'); - - // Store only the session ID in the cookie (much smaller) - setCookie(event, 'user_session', sessionId, cookieOptions); - - console.log('โœ… Session ID cookie set in response headers (will be available in next request)'); - - console.log('โœ… Session cookie created successfully'); - console.log('๐Ÿช Cookie details:'); - console.log(' - Path: /'); - console.log(' - Secure:', isSecure); - console.log(' - SameSite: lax'); - console.log(' - HttpOnly: true'); - console.log(' - MaxAge:', SESSION_DURATION, 'seconds'); - console.log(' - Host:', event.node.req.headers.host); - - // 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); - } - - // IMPORTANT: Ensure cookie is set before redirect - // The cookie should be in the Set-Cookie header of the redirect response - console.log('โ†ช๏ธ Redirecting to dashboard with cookie in response headers...'); - - // Note: In H3/Nitro, setCookie automatically adds Set-Cookie header to response - // The cookie will be available in the browser after the redirect - // We can't verify it in the same request, but it should be set correctly - - // Use sendRedirect - it should include the Set-Cookie header - // The browser will receive the cookie and include it in the next request - return sendRedirect(event, '/dashboard?authenticated=true', 302); - - } catch (error: any) { - console.error('โŒ === CALLBACK ERROR ==='); - console.error('โŒ Error message:', error.message); - console.error('โŒ Error stack:', error.stack); - console.error('โŒ =================='); - - const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`); - return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + const errorMsg = encodeURIComponent( + `Keycloak error: ${error} - ${errorDescription || "Please try again"}`, + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); } -}); \ No newline at end of file + + console.log("๐Ÿ“ Code received:", !!code); + console.log("๐ŸŽฒ State from URL:", state); + console.log("๐ŸŽฒ State from cookie:", storedState); + console.log("๐ŸŽฒ State validation:", state === storedState); + + if (!state || state !== storedState) { + console.error("โŒ Invalid state parameter - possible CSRF attack"); + console.error(" Expected:", storedState); + console.error(" Received:", state); + + const errorMsg = encodeURIComponent( + "Security validation failed. Please try logging in again.", + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + deleteCookie(event, "oauth_state"); + + if (!code) { + console.error("โŒ Authorization code not provided"); + const errorMsg = encodeURIComponent( + "No authorization code received from Keycloak.", + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + // Validate Keycloak configuration + if (!config.keycloakIssuer) { + console.error("โŒ KEYCLOAK_ISSUER is not configured"); + const errorMsg = encodeURIComponent( + "Keycloak server is not configured. Please contact administrator.", + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + if (!config.keycloakClientId || !config.keycloakClientSecret) { + console.error("โŒ Keycloak client credentials are not configured"); + const errorMsg = encodeURIComponent( + "Keycloak client credentials are missing. Please contact administrator.", + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`; + const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`; + + console.log("๐Ÿ”— Token URL:", tokenUrl); + console.log("๐Ÿ”— Redirect URI:", redirectUri); + console.log( + "๐Ÿ”‘ Client ID:", + config.keycloakClientId ? "***configured***" : "MISSING", + ); + + const tokenPayload = new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.keycloakClientId, + client_secret: config.keycloakClientSecret, + code, + redirect_uri: redirectUri, + }); + + let tokenResponse; + try { + // Create abort controller for timeout (compatible with all Node.js versions) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: tokenPayload, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + } catch (fetchError: any) { + console.error("โŒ Fetch error details:"); + console.error(" - Error type:", fetchError.name); + console.error(" - Error message:", fetchError.message); + console.error(" - Token URL attempted:", tokenUrl); + + // Provide more specific error messages + let errorMsg = "Failed to connect to authentication server."; + if ( + fetchError.name === "AbortError" || + fetchError.message.includes("timeout") + ) { + errorMsg = "Authentication server timeout. Please try again."; + } else if ( + fetchError.message.includes("ENOTFOUND") || + fetchError.message.includes("getaddrinfo") + ) { + errorMsg = + "Cannot reach authentication server. Please check network connection."; + } else if (fetchError.message.includes("ECONNREFUSED")) { + errorMsg = + "Authentication server refused connection. Server may be down."; + } else if ( + fetchError.message.includes("certificate") || + fetchError.message.includes("SSL") + ) { + errorMsg = "SSL certificate error. Please contact administrator."; + } + + const encodedError = encodeURIComponent(errorMsg); + return sendRedirect(event, `/LoginPage?error=${encodedError}`); + } + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error("โŒ Token exchange failed:", errorText); + const errorMsg = encodeURIComponent( + `Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`, + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + const tokens = await tokenResponse.json(); + + // Parse token payloads for immediate availability + let accessTokenPayload; + let idTokenPayloadFull; + try { + accessTokenPayload = JSON.parse( + Buffer.from(tokens.access_token.split(".")[1], "base64").toString(), + ); + idTokenPayloadFull = JSON.parse( + Buffer.from(tokens.id_token.split(".")[1], "base64").toString(), + ); + } catch (parseError) { + console.error("โŒ Failed to parse token payloads:", parseError); + const errorMsg = encodeURIComponent("Invalid token format"); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } + + // Create session data + const sessionData = { + user: { + id: idTokenPayloadFull.sub, + email: idTokenPayloadFull.email, + name: idTokenPayloadFull.name || idTokenPayloadFull.preferred_username, + preferred_username: idTokenPayloadFull.preferred_username, + }, + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + SESSION_DURATION * 1000, + createdAt: Date.now(), + scope: accessTokenPayload.scope || "openid email profile", + status: "authenticated", + }; + + // Store session in server-side store and get session ID + const { createSession } = await import('~/server/utils/sessionStore'); + const sessionId = createSession(sessionData); + + // Determine if we should use secure cookies + const isSecure = + process.env.NODE_ENV === "production" && + event.node.req.headers["x-forwarded-proto"] === "https"; + + console.log("๐Ÿ”— Setting session ID cookie"); + console.log("โฑ๏ธ Session duration:", SESSION_DURATION, "seconds"); + + const cookieOptions: any = { + httpOnly: true, + secure: isSecure, + sameSite: "lax" as const, + maxAge: SESSION_DURATION, + path: "/", + }; + + // Store only session ID in cookie (small size, ~64 bytes) + setCookie(event, "user_session", sessionId, cookieOptions); + + console.log("โœ… Session ID cookie created successfully"); + console.log("๐Ÿช Cookie details:"); + console.log(" - Name: user_session"); + console.log(" - Size:", sessionId.length, "bytes"); + console.log(" - Path: /"); + console.log(" - Secure:", isSecure); + console.log(" - SameSite: lax"); + console.log(" - HttpOnly: true"); + console.log(" - MaxAge:", SESSION_DURATION, "seconds"); + console.log(" - Host:", event.node.req.headers.host); + + // 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); + } + + // IMPORTANT: Ensure cookie is set before redirect + // The cookie should be in the Set-Cookie header of the redirect response + console.log( + "โ†ช๏ธ Redirecting to dashboard with cookie in response headers...", + ); + + // Note: In H3/Nitro, setCookie automatically adds Set-Cookie header to response + // The cookie will be available in the browser after the redirect + // We can't verify it in the same request, but it should be set correctly + + // Use sendRedirect - it should include the Set-Cookie header + // The browser will receive the cookie and include it in the next request + return sendRedirect(event, "/dashboard?authenticated=true", 302); + } catch (error: any) { + console.error("โŒ === CALLBACK ERROR ==="); + console.error("โŒ Error message:", error.message); + console.error("โŒ Error stack:", error.stack); + console.error("โŒ =================="); + + const errorMsg = encodeURIComponent( + `Authentication failed: ${error.message}`, + ); + return sendRedirect(event, `/LoginPage?error=${errorMsg}`); + } +}); diff --git a/server/api/auth/keycloak-login.ts b/server/api/auth/keycloak-login.ts index 3bf6661..24a53ad 100644 --- a/server/api/auth/keycloak-login.ts +++ b/server/api/auth/keycloak-login.ts @@ -7,14 +7,15 @@ export default defineEventHandler(async (event) => { // === STALE SESSION CLEANUP === // Check for existing session and clean up if invalid/expired - const existingSessionId = getCookie(event, 'user_session') + const existingSessionCookie = getCookie(event, 'user_session') - if (existingSessionId) { + if (existingSessionCookie) { console.log('๐Ÿ” Existing session cookie found, validating...') try { - const { getSession, deleteSession } = await import('~/server/utils/sessionStore') - const session = getSession(existingSessionId) + // Decode JWT-based session from cookie + const sessionJson = Buffer.from(existingSessionCookie, 'base64').toString('utf-8'); + const session = JSON.parse(sessionJson); if (session) { // Check if session is expired @@ -22,22 +23,15 @@ export default defineEventHandler(async (event) => { if (isExpired) { console.log('๐Ÿงน Cleaning up expired session...') - deleteSession(existingSessionId) deleteCookie(event, 'user_session') deleteCookie(event, 'oauth_state') console.log('โœ… Expired session cleared') } else { console.log('โš ๏ธ Valid session exists, clearing to allow fresh login...') - deleteSession(existingSessionId) deleteCookie(event, 'user_session') deleteCookie(event, 'oauth_state') console.log('โœ… Existing session cleared for fresh login') } - } else { - console.log('๐Ÿงน Session cookie exists but no session in store, clearing cookie...') - deleteCookie(event, 'user_session') - deleteCookie(event, 'oauth_state') - console.log('โœ… Stale cookie cleared') } } catch (error) { console.warn('โš ๏ธ Error during session cleanup:', error) diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index 8236903..ed8e6b7 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -4,22 +4,19 @@ export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); console.log('๐Ÿšช Logout handler called'); - // Get the current session to retrieve tokens - const sessionId = getCookie(event, 'user_session'); + // Get the current session to retrieve ID token for proper Keycloak logout + const sessionCookie = getCookie(event, 'user_session'); let idToken = null; - if (sessionId) { + if (sessionCookie) { try { - const { getSession, deleteSession } = await import('~/server/utils/sessionStore'); - const session = getSession(sessionId); - if (session) { - idToken = session.idToken; - console.log('๐Ÿ”‘ ID token found in session:', !!idToken); - // Delete session from store - deleteSession(sessionId); - } + // Decode JWT-based session from cookie + const sessionJson = Buffer.from(sessionCookie, 'base64').toString('utf-8'); + const session = JSON.parse(sessionJson); + idToken = session.idToken; + console.log('๐Ÿ”‘ ID token found in session:', !!idToken); } catch (error) { - console.warn('โš ๏ธ Could not retrieve session:', error); + console.warn('โš ๏ธ Could not parse session cookie:', error); } } else { console.warn('โš ๏ธ No session cookie found'); diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts index 19ddb3b..c2b0594 100644 --- a/server/api/auth/session.get.ts +++ b/server/api/auth/session.get.ts @@ -1,74 +1,73 @@ // server/api/auth/session.get.ts import type { SessionResponse } from '~/types/auth' -// Helper function to safely decode the JWT payload (Access Token or ID Token) -const decodeTokenPayload = (token: string | undefined): any | null => { - if (!token) return null; - try { - // Tokens are base64 encoded and separated by '.' - const parts = token.split("."); - if (parts.length < 2) return null; // Not a valid JWT format - - const payloadBase64 = parts[1]; - - // Decode from base64 and parse the JSON - // Note: Using Buffer.from is standard in Node.js server environments (like Nitro/H3) - return JSON.parse(Buffer.from(payloadBase64, "base64").toString()); - } catch (e) { - console.error("โŒ Failed to decode token payload:", e); - return null; - } -}; - -// --- START OF THE SINGLE EXPORT DEFAULT HANDLER --- export default defineEventHandler(async (event) => { - console.log("๐Ÿ” Session endpoint called"); + console.log('๐Ÿ” Session endpoint called'); - const sessionId = getCookie(event, "user_session"); - console.log("๐Ÿช Session cookie exists:", !!sessionId); + const sessionCookie = getCookie(event, 'user_session'); + console.log('๐Ÿช Session cookie exists:', !!sessionCookie); - if (!sessionId) { - console.log("โŒ No session cookie found"); + if (!sessionCookie) { + console.log('โŒ No session cookie found'); throw createError({ statusCode: 401, - statusMessage: "No session cookie found", + statusMessage: 'No session cookie found', }); } try { - // Get session from server-side store using session ID + // Get session from store using session ID const { getSession } = await import('~/server/utils/sessionStore'); - const session = getSession(sessionId); + const session = getSession(sessionCookie); if (!session) { - console.log("โŒ Session not found or expired"); - deleteCookie(event, "user_session"); + console.log('โŒ Session not found in store or expired'); + deleteCookie(event, 'user_session'); throw createError({ statusCode: 401, - statusMessage: "Session expired or invalid", + statusMessage: 'Session not found or expired', }); } - console.log("๐Ÿ“‹ Session retrieved from store successfully"); + console.log('๐Ÿ“‹ Session retrieved from store successfully'); + + // Parse token payloads on-demand from tokens + let accessTokenPayload = null; + let idTokenPayload = null; + + try { + if (session.accessToken) { + const accessParts = session.accessToken.split('.'); + if (accessParts.length >= 2) { + accessTokenPayload = JSON.parse(Buffer.from(accessParts[1], 'base64').toString()); + } + } + + if (session.idToken) { + const idParts = session.idToken.split('.'); + if (idParts.length >= 2) { + idTokenPayload = JSON.parse(Buffer.from(idParts[1], 'base64').toString()); + } + } + } catch (parseError) { + console.warn('โš ๏ธ Failed to parse token payloads:', parseError); + } + const isExpired = Date.now() > session.expiresAt; - console.log(" ย  Is Expired:", isExpired); + console.log(' Is Expired:', isExpired); - // Check if the token has expired + // Check if the session has expired if (isExpired) { - console.log("โฐ Session has expired, clearing cookie"); - deleteCookie(event, "user_session"); + console.log('โฐ Session has expired, clearing cookie'); + deleteCookie(event, 'user_session'); throw createError({ statusCode: 401, - statusMessage: "Session expired", + statusMessage: 'Session expired', }); } - // Decode tokens and prepare the enhanced response data - const idTokenPayload = decodeTokenPayload(session.idToken); - const accessTokenPayload = decodeTokenPayload(session.accessToken); - - // Final response object - ensure it matches SessionResponse interface + // Return the full session data (already includes parsed payloads) const sessionResponse: SessionResponse & { idTokenPayload?: any accessTokenPayload?: any @@ -76,37 +75,55 @@ export default defineEventHandler(async (event) => { status?: string remainingSeconds?: number idToken?: string + scope?: string + createdAt?: number } = { success: true, // Basic User Info user: session.user, - // Raw Tokens (optional in SessionResponse) + // Raw Tokens accessToken: session.accessToken, refreshToken: session.refreshToken, + idToken: session.idToken, - // Session Timestamps (optional in SessionResponse) + // Session Timestamps expiresAt: session.expiresAt, + createdAt: session.createdAt, remainingSeconds: Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000)), - // Additional debug fields (not in SessionResponse interface) - idToken: session.idToken, + // Parsed token payloads (parsed on-demand, not stored in cookie) idTokenPayload: idTokenPayload, accessTokenPayload: accessTokenPayload, + + // Full session for debugging fullSessionObject: session, - status: "authenticated", + + // OAuth metadata + scope: session.scope, + status: session.status || 'authenticated', }; - console.log("โœ… Session is valid, returning full session data"); + console.log('โœ… Session is valid, returning full session data'); return sessionResponse; - } catch (parseError) { - console.error("โŒ Failed to parse session cookie:", parseError); - // If JSON parsing fails or any other error occurs, the session is invalid - deleteCookie(event, "user_session"); + } catch (parseError: any) { + console.error('โŒ Failed to parse session cookie:', parseError); + + // Check if this is an old session ID format (not base64 JSON) + if (parseError.message?.includes('Unexpected token') || parseError.message?.includes('JSON')) { + console.log('๐Ÿงน Detected old session format, clearing cookie...'); + deleteCookie(event, 'user_session'); + throw createError({ + statusCode: 401, + statusMessage: 'Old session format detected. Please login again.', + }); + } + + // If parsing fails, the session is invalid + deleteCookie(event, 'user_session'); throw createError({ statusCode: 401, - statusMessage: "Invalid session data", + statusMessage: 'Invalid session data', }); } }); -// --- END OF THE SINGLE EXPORT DEFAULT HANDLER --- diff --git a/server/api/auth/validate-session.post.ts b/server/api/auth/validate-session.post.ts index 7b759c1..2e28f4a 100644 --- a/server/api/auth/validate-session.post.ts +++ b/server/api/auth/validate-session.post.ts @@ -1,22 +1,23 @@ // server/api/auth/validate-session.post.ts export default defineEventHandler(async (event) => { const config = useRuntimeConfig() - const sessionId = getCookie(event, 'user_session') + const sessionCookie = getCookie(event, 'user_session') console.log('๐Ÿ” Session validation endpoint called') - console.log('๐Ÿช Session cookie exists:', !!sessionId) + console.log('๐Ÿช Session cookie exists:', !!sessionCookie) - if (!sessionId) { + if (!sessionCookie) { console.log('โŒ No session cookie found') return { valid: false, reason: 'no_session' } } try { - const { getSession, deleteSession } = await import('~/server/utils/sessionStore') - const session = getSession(sessionId) + // Decode JWT-based session from cookie + const sessionJson = Buffer.from(sessionCookie, 'base64').toString('utf-8'); + const session = JSON.parse(sessionJson); if (!session) { - console.log('โŒ Session not found in store') + console.log('โŒ Session not found or invalid') deleteCookie(event, 'user_session') return { valid: false, reason: 'session_not_found' } } @@ -24,7 +25,6 @@ export default defineEventHandler(async (event) => { // Check local expiry if (Date.now() > session.expiresAt) { console.log('โฐ Session has expired locally') - deleteSession(sessionId) deleteCookie(event, 'user_session') return { valid: false, reason: 'session_expired' } } @@ -53,7 +53,6 @@ export default defineEventHandler(async (event) => { // If Keycloak returns 401, the session is invalid on their side if (keycloakError.status === 401 || keycloakError.statusCode === 401) { console.log('โŒ Keycloak session has expired (401)') - deleteSession(sessionId) deleteCookie(event, 'user_session') return { valid: false, reason: 'keycloak_session_expired' } } diff --git a/server/api/external/validate-token.post.ts b/server/api/external/validate-token.post.ts new file mode 100644 index 0000000..700111e --- /dev/null +++ b/server/api/external/validate-token.post.ts @@ -0,0 +1,77 @@ +import { defineEventHandler, createError } from 'h3'; +import { getSessionFromCookie } from '~/server/utils/sessionStore'; + +export default defineEventHandler(async (event) => { + console.log('๐ŸŒ Proxy: External token validation requested'); + + const config = useRuntimeConfig(); + + try { + // 1. Get current session to retrieve access token + const session = await getSessionFromCookie(event); + + if (!session || !session.accessToken) { + console.log('โŒ Proxy: No valid session or access token found'); + throw createError({ + statusCode: 401, + statusMessage: 'Unauthorized: No valid session found', + }); + } + + const accessToken = session.accessToken; + + // Log token details for debugging audience and claims + try { + const payloadPart = accessToken.split('.')[1]; + const payload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()); + console.log('๐ŸŽซ Full Token Payload:', JSON.stringify(payload, null, 2)); + console.log('๐ŸŽซ Token Audience (aud):', payload.aud); + } catch (e) { + console.warn('โš ๏ธ Proxy: Failed to parse token for logging'); + } + + const externalApiUrl = `${config.externalApiBaseUrl}/api/v1/auth/me`; + + console.log(`๐Ÿ“ก Proxy: Calling external API: ${externalApiUrl}`); + + // 2. Call external API with Bearer token + // We use $fetch from ofetch (auto-imported in Nuxt/Nitro) + const response = await $fetch(externalApiUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json', + }, + timeout: config.externalApiTimeout || 10000, + }); + + console.log('โœ… Proxy: External API call successful'); + + return { + success: true, + data: response, + timestamp: new Date().toISOString() + }; + + } catch (error: any) { + console.error('โŒ Proxy: External API call failed:', error.message); + + // Check if it's an HTTP error from the external API + const statusCode = error.response?.status || 500; + const statusText = error.response?.statusText || 'External API Error'; + const errorData = error.response?._data || error.data || null; + + console.error(` Status: ${statusCode} ${statusText}`); + if (errorData) { + console.error(' Error Data:', JSON.stringify(errorData)); + } + + return { + success: false, + error: statusText, + statusCode: statusCode, + details: errorData || error.message, + timestamp: new Date().toISOString() + }; + } +}); diff --git a/server/utils/sessionStore.ts b/server/utils/sessionStore.ts index 72bc92a..a1a3a85 100644 --- a/server/utils/sessionStore.ts +++ b/server/utils/sessionStore.ts @@ -1,63 +1,91 @@ // server/utils/sessionStore.ts -// Simple in-memory session store (for development) -// In production, use Redis or a database - -import { getCookie } from 'h3' -import { randomBytes } from 'crypto' +// Lightweight in-memory session store with automatic cleanup interface SessionData { - user: any; - accessToken: string; - idToken: string; - refreshToken: string; - expiresAt: number; - createdAt: number; + user: any; + accessToken: string; + idToken: string; + refreshToken: string; + expiresAt: number; + createdAt: number; + scope?: string; + status?: string; } +// In-memory session storage const sessions = new Map(); -// Clean up expired sessions every 5 minutes +// Cleanup expired sessions every 5 minutes setInterval(() => { - const now = Date.now(); - for (const [sessionId, session] of sessions.entries()) { - if (session.expiresAt < now) { - sessions.delete(sessionId); - } + const now = Date.now(); + let cleanedCount = 0; + + for (const [sessionId, session] of sessions.entries()) { + if (session.expiresAt < now) { + sessions.delete(sessionId); + cleanedCount++; } -}, 5 * 60 * 1000); + } + + if (cleanedCount > 0) { + console.log(`๐Ÿงน Cleaned up ${cleanedCount} expired sessions. Active sessions: ${sessions.size}`); + } +}, 5 * 60 * 1000); // Every 5 minutes export function createSession(data: SessionData): string { - // Generate a secure random session ID - const sessionId = randomBytes(32).toString('hex'); - sessions.set(sessionId, data); - return sessionId; + // Generate random session ID + const sessionId = Array.from({ length: 32 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(''); + + sessions.set(sessionId, data); + console.log(`โœ… Session created: ${sessionId.substring(0, 8)}... (Total active: ${sessions.size})`); + + return sessionId; } export function getSession(sessionId: string): SessionData | null { - const session = sessions.get(sessionId); - if (!session) { - return null; - } - - // Check if expired - if (session.expiresAt < Date.now()) { - sessions.delete(sessionId); - return null; - } - - return session; + const session = sessions.get(sessionId); + + if (!session) { + return null; + } + + // Check if expired + if (session.expiresAt < Date.now()) { + sessions.delete(sessionId); + return null; + } + + return session; } export function deleteSession(sessionId: string): void { - sessions.delete(sessionId); + sessions.delete(sessionId); + console.log(`๐Ÿ—‘๏ธ Session deleted: ${sessionId.substring(0, 8)}... (Remaining: ${sessions.size})`); } -// Helper function to get session from cookie (for use in API handlers) +// Helper function to get session from cookie (for API handlers) export async function getSessionFromCookie(event: any): Promise { - const sessionId = getCookie(event, 'user_session'); - if (!sessionId) { - return null; - } - return getSession(sessionId); + const { getCookie } = await import('h3'); + const sessionId = getCookie(event, 'user_session'); + + if (!sessionId) { + return null; + } + + return getSession(sessionId); } +// Get session stats +export function getSessionStats() { + return { + totalSessions: sessions.size, + sessions: Array.from(sessions.entries()).map(([id, session]) => ({ + id: id.substring(0, 8) + '...', + user: session.user?.email || session.user?.name, + expiresAt: new Date(session.expiresAt).toISOString(), + isExpired: session.expiresAt < Date.now() + })) + }; +} diff --git a/stores/queueStore.js b/stores/queueStore.js index 90bf290..d296dd9 100644 --- a/stores/queueStore.js +++ b/stores/queueStore.js @@ -4,6 +4,7 @@ import { ref, computed, watch } from 'vue'; import { useClinicStore } from './clinicStore'; import { usePenunjangStore } from './penunjangStore'; import { useLoketStore } from './loketStore'; +import { useWebSocket } from '@/composables/useWebSocket'; export const useQueueStore = defineStore('queue', () => { const clinicStore = useClinicStore(); @@ -26,6 +27,77 @@ export const useQueueStore = defineStore('queue', () => { const lastUpdated = ref(Date.now()); // synchronization guard (moved lower) + // ============================================ + // WEBSOCKET INTEGRATION (CENTRALIZED) + // ============================================ + const wsInstance = ref(null); + const isWsConnected = ref(false); + const wsClientId = ref(`client-${Math.random().toString(36).substring(7)}`); + + /** + * Initialize Global WebSocket + */ + const initWebSocket = (customClientId = null) => { + if (wsInstance.value && isWsConnected.value) { + console.log('๐Ÿ”Œ [queueStore] WebSocket already connected.'); + return; + } + + if (customClientId) { + wsClientId.value = customClientId; + } + + const config = useRuntimeConfig(); + const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.150.100:8084/api/v1/ws"; + + console.log(`๐Ÿ”Œ [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`); + + wsInstance.value = useWebSocket({ + url: wsBaseUrl, + clientId: wsClientId.value, + onOpen: () => { + console.log('โœ… [queueStore] WebSocket connected'); + isWsConnected.value = true; + }, + onClose: () => { + console.log('โŒ [queueStore] WebSocket disconnected'); + isWsConnected.value = false; + }, + onError: (err) => { + console.error('โš ๏ธ [queueStore] WebSocket error:', err); + isWsConnected.value = false; + }, + onMessage: (data) => { + console.log('๐Ÿ“จ [queueStore] Global WS Message:', data); + + // TRIGGER STRATEGIC REFRESHES + // 1. Refetch patients for all active lokets in the store + Object.keys(apiPatientsPerLoket.value).forEach(loketId => { + fetchPatientsForLoket(loketId); + }); + + // 2. Refetch clinics to update quotas/availability + clinicStore.fetchRegulerClinics(); + + // 3. Ensure base data is synced + ensureInitialData(); + } + }); + + wsInstance.value.connect(); + }; + + /** + * Disconnect Global WebSocket + */ + const disconnectWebSocket = () => { + if (wsInstance.value) { + wsInstance.value.disconnect(); + wsInstance.value = null; + isWsConnected.value = false; + } + }; + /** * Sync patient status to apiPatientsPerLoket for reactivity */ @@ -2485,6 +2557,9 @@ export const useQueueStore = defineStore('queue', () => { checkAndResetDaily, isTodayPatient, getResetThreshold, + initWebSocket, + disconnectWebSocket, + isWsConnected, }; }, {