push update socket data dan token

This commit is contained in:
Fanrouver
2026-02-10 09:51:17 +07:00
parent 101dc38910
commit fb70237a12
22 changed files with 1630 additions and 827 deletions
+1
View File
@@ -2,6 +2,7 @@
@use "./variables" as *;
@use "./colors" as *;
@use "./typography" as *;
@use "./pages/dashboard" as *;
// Global styles
*,
+179
View File
@@ -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;
}
}
}
+22 -43
View File
@@ -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');
+4
View File
@@ -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",
+47 -6
View File
@@ -791,7 +791,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useQueueStore } from '@/stores/queueStore';
import { useMasterStore } from '@/stores/masterStore';
@@ -936,16 +936,43 @@ const filterOptionsList = {
const config = useRuntimeConfig();
const wsBaseUrl = config.public?.wsBaseUrl || 'ws://10.10.150.100:8084/api/v1/ws';
// WebSocket client ID for admin (you can make this dynamic)
const adminClientId = `admin-klinik-ruang-${kodeKlinik.value}`;
// Generate a unique session suffix (random ID)
const uniqueSessionSuffix = ref(process.client ? Math.random().toString(36).substring(2, 8) : '')
// WebSocket client ID for admin
const adminClientId = computed(() => {
return `admin-klinik-ruang-${kodeKlinik.value}-${uniqueSessionSuffix.value}`
})
const fetchAllData = async () => {
if (!klinikData.value) return;
console.log('🔄 AdminKlinikRuang refresh: Syncing data...');
try {
await fetchPatientsFromAPI();
queueStore.ensureInitialData();
console.log('✅ AdminKlinikRuang refresh: Success');
} catch (err) {
console.error('❌ AdminKlinikRuang refresh error:', err);
}
};
// Initialize WebSocket for sending messages
const { sendViaPost } = useWebSocket({
url: wsBaseUrl,
clientId: adminClientId,
clientId: adminClientId.value,
fallbackPostUrl: '/stats-api/ws'
});
const isConnected = computed(() => queueStore.isWsConnected);
// Watch for clientId changes and reconnect if needed
watch(adminClientId, (newClientId, oldClientId) => {
if (newClientId && newClientId !== oldClientId) {
console.log('🔄 Client ID changed, reconnecting centralized WebSocket...')
queueStore.initWebSocket(newClientId)
}
})
const showSnackbar = (message, color = 'success') => {
snackbarText.value = message;
snackbarColor.value = color;
@@ -2269,9 +2296,23 @@ onMounted(async () => {
if (!klinikData.value) {
console.warn('⚠️ Klinik data not found after sync');
// We don't automatically redirect anymore to allow sync to complete
// but we can check again after a short delay or just let the computed handle it
}
// 3. Centralized WebSocket & polling
await nextTick();
// Initial fetch/sync
await fetchAllData();
// Polling every 10 seconds to keep data synchronized as fallback
const pollingInterval = setInterval(fetchAllData, 10000);
// Use centralized WebSocket
queueStore.initWebSocket(adminClientId.value);
onUnmounted(() => {
clearInterval(pollingInterval);
});
});
</script>
+66 -76
View File
@@ -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);
+75 -70
View File
@@ -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 [];
+12 -12
View File
@@ -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()
@@ -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)
}
})
</script>
+53 -26
View File
@@ -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
})
</script>
+43 -42
View File
@@ -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;
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
// Simple page to clear session and redirect to Keycloak logout
const router = useRouter();
onMounted(async () => {
try {
console.log('🧹 Clearing session...');
const response = await $fetch('/api/auth/clear-session', {
method: 'POST'
});
console.log('✅ Session cleared, redirecting to Keycloak logout...');
// Redirect to Keycloak logout URL
if (response.logoutUrl) {
window.location.href = response.logoutUrl;
} else {
// Fallback to login page
router.push('/LoginPage?logout=success');
}
} catch (error) {
console.error('❌ Error clearing session:', error);
// On error, just go to login page
router.push('/LoginPage?error=logout_failed');
}
});
</script>
<template>
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<p class="mt-4 text-lg">Clearing session and logging out...</p>
</div>
</div>
</template>
+406 -115
View File
@@ -1,16 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed } from "vue";
definePageMeta({
middleware:['auth']
})
middleware: ["auth"],
});
// Define a type for the data structure you now return from the API
interface SessionData {
user: {
name: string;
email: string;
// ... add other user fields
roles?: string | string[];
};
status: string;
createdAt: number;
@@ -27,36 +26,106 @@ const sessionData = ref<SessionData | null>(null);
const loading = ref(true);
const authError = ref<any>(null);
// Computeds for easy display
const sessionExpiresDate = computed(() => {
if (!sessionData.value?.expiresAt) return 'N/A';
return new Date(sessionData.value.expiresAt).toLocaleString();
if (!sessionData.value?.expiresAt) return "N/A";
return new Date(sessionData.value.expiresAt).toLocaleString("id-ID");
});
const sessionCreatedDate = computed(() => {
if (!sessionData.value?.createdAt) return 'N/A';
return new Date(sessionData.value.createdAt).toLocaleString();
if (!sessionData.value?.createdAt) return "N/A";
return new Date(sessionData.value.createdAt).toLocaleString("id-ID");
});
const currentDateTime = computed(() => new Date().toLocaleString());
const remainingTime = computed(() => {
if (!sessionData.value?.expiresAt) return "N/A";
const remaining = sessionData.value.expiresAt - Date.now();
if (remaining <= 0) return "Expired";
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((remaining % (1000 * 60)) / 1000);
return `${hours}h ${minutes}m ${seconds}s`;
});
const tokenIssuedAt = computed(() => {
if (!sessionData.value?.accessTokenPayload?.iat) return "N/A";
return new Date(
sessionData.value.accessTokenPayload.iat * 1000,
).toLocaleString("id-ID");
});
const tokenExpiresAt = computed(() => {
if (!sessionData.value?.accessTokenPayload?.exp) return "N/A";
return new Date(
sessionData.value.accessTokenPayload.exp * 1000,
).toLocaleString("id-ID");
});
// Helper to display JSON data nicely
const formatJson = (data: any) => {
return JSON.stringify(data, null, 2);
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
alert("Berhasil disalin ke clipboard!");
} catch (err) {
console.error("Gagal menyalin: ", err);
}
};
const expandedSections = ref<Record<string, boolean>>({
idToken: false,
accessToken: false,
refreshToken: false,
});
// External API Validation State
const externalValidationLoading = ref(false);
const externalValidationResult = ref<any>(null);
const externalValidationError = ref<string | null>(null);
const testTokenValidation = async () => {
try {
externalValidationLoading.value = true;
externalValidationError.value = null;
externalValidationResult.value = null;
console.log("📡 Testing token validation with external API proxy...");
const response = await $fetch<any>("/api/external/validate-token", {
method: "POST",
});
externalValidationResult.value = response;
if (!response.success) {
externalValidationError.value = response.error || "Validation failed";
}
console.log("✅ Validation test completed:", response);
} catch (e: any) {
console.error("❌ Validation test failed:", e);
externalValidationError.value = e.data?.message || e.message || "Request failed";
} finally {
externalValidationLoading.value = false;
}
};
const toggleSection = (section: string) => {
expandedSections.value[section] = !expandedSections.value[section];
};
onMounted(async () => {
try {
// Fetch the enhanced session data from your API
const data = await $fetch<SessionData>('/api/auth/session');
const data = await $fetch<SessionData>("/api/auth/session");
sessionData.value = data;
authError.value = null;
} catch (e: any) {
console.error('Failed to fetch session data:', e);
// Store the error status for display
authError.value = e.data?.statusMessage || 'Session check failed. Please log in.';
console.error("Failed to fetch session data:", e);
authError.value =
e.data?.statusMessage || "Session check failed. Please log in.";
sessionData.value = null;
} finally {
loading.value = false;
@@ -65,110 +134,332 @@ onMounted(async () => {
</script>
<template>
<div class="container mx-auto p-4 max-w-4xl">
<h1 class="text-3xl font-bold mb-6 border-b pb-2">Complete Session Data Debug Page</h1>
<div class="dashboard-container">
<div class="dashboard-content">
<div class="dashboard-header">
<h1>Complete Session Data</h1>
</div>
<div v-if="loading" class="text-center p-8">
<p class="text-xl">Loading session data...</p>
</div>
<div v-else-if="authError" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong class="font-bold">Authentication Error:</strong>
<span class="block sm:inline">{{ authError }}</span>
<p class="mt-2">If you expect to be logged in, the session may have expired or the cookie is missing/invalid.</p>
<NuxtLink to="/LoginPage" class="text-blue-600 hover:underline">Go to Login Page</NuxtLink>
</div>
<div v-else-if="sessionData">
<section class="mb-6 border p-4 rounded-lg bg-gray-50">
<h2 class="text-xl font-semibold mb-3">Basic User Information</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<p><strong>Name:</strong> {{ sessionData.user.name }}</p>
<p><strong>Email:</strong> {{ sessionData.user.email }}</p>
<p><strong>Status:</strong> <span class="text-green-600 font-medium">{{ sessionData.status }}</span></p>
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
<p><strong>Created At:</strong> {{ sessionCreatedDate }}</p>
<div v-if="loading" class="dashboard-body">
<div class="text-center p-8">
<v-progress-circular
indeterminate
color="primary"
size="48"
></v-progress-circular>
<p class="mt-4">Memuat data sesi...</p>
</div>
</section>
</div>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Token Information (Raw)</h2>
<div class="space-y-3">
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token (session.idToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.idToken }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token (session.accessToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.accessToken }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Refresh Token (session.refreshToken)</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ sessionData.refreshToken || 'N/A' }}</pre>
</details>
<div v-else-if="authError" class="dashboard-body">
<div class="bg-red-50 border-l-4 border-red-500 p-4 rounded">
<h3 class="font-bold text-red-800">Authentication Error</h3>
<p class="text-red-700">{{ authError }}</p>
<NuxtLink
to="/LoginPage"
class="text-blue-600 hover:underline mt-2 inline-block"
>
Go to Login Page
</NuxtLink>
</div>
</section>
</div>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Parsed Token Payloads</h2>
<div class="space-y-3">
<details open>
<summary class="cursor-pointer font-medium hover:text-blue-600">Access Token Payload</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.accessTokenPayload) }}</pre>
</details>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">ID Token Payload</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.idTokenPayload) }}</pre>
</details>
<div v-else-if="sessionData" class="dashboard-body">
<!-- External API Validation Section -->
<div class="section mb-8 bg-blue-50 p-6 rounded-lg border border-blue-200">
<div class="flex justify-between items-center mb-4">
<h3 class="section-title mb-0 border-0 pb-0">External API Token Validation</h3>
<v-btn
color="primary"
:loading="externalValidationLoading"
prepend-icon="mdi-shield-check"
@click="testTokenValidation"
>
Test Token Validation
</v-btn>
</div>
<p class="text-sm text-gray-600 mb-4">
Test and validate your current access token against the external API endpoint:
<code class="bg-blue-100 px-1 rounded text-blue-800">http://10.10.150.100:8084/api/v1/auth/me</code>
</p>
<!-- Validation Status -->
<div v-if="externalValidationResult || externalValidationError" class="mt-4">
<v-alert
v-if="externalValidationResult?.success"
type="success"
variant="tonal"
title="Validation Successful"
class="mb-4"
>
The access token is valid and accepted by the external API.
</v-alert>
<v-alert
v-else-if="externalValidationError"
type="error"
variant="tonal"
title="Validation Failed"
class="mb-4"
>
{{ externalValidationError }}
<div v-if="externalValidationResult?.details" class="mt-2 text-xs">
<strong>Details:</strong> {{ externalValidationResult.details }}
</div>
</v-alert>
<!-- Result Data -->
<div v-if="externalValidationResult?.data" class="bg-white p-4 rounded border border-gray-200 mt-2">
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">External API Response:</h4>
<div class="payload-content text-xs">
{{ formatJson(externalValidationResult.data) }}
</div>
</div>
</div>
</div>
</section>
<section class="mb-6 border p-4 rounded-lg">
<h2 class="text-xl font-semibold mb-3">Complete Raw Session Data (Debug)</h2>
<details>
<summary class="cursor-pointer font-medium hover:text-blue-600">Full Session Object</summary>
<pre class="whitespace-pre-wrap bg-gray-100 p-3 mt-1 text-xs">{{ formatJson(sessionData.fullSessionObject) }}</pre>
</details>
</section>
<section class="mb-6 p-4 rounded-lg border-t-2 border-dashed">
<h2 class="text-xl font-semibold mb-3">Session Timeline</h2>
<ul class="space-y-2">
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Session Created:</strong> {{ sessionCreatedDate }}</p>
</li>
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Current Time:</strong> {{ currentDateTime }}</p>
</li>
<li class="flex items-center space-x-2">
<span class="w-2 h-2 bg-black rounded-full"></span>
<p><strong>Session Expires:</strong> {{ sessionExpiresDate }}</p>
</li>
</ul>
</section>
<!-- Basic User Information -->
<div class="section">
<h3 class="section-title">Basic User Information</h3>
<div class="info-grid">
<div class="label">Name:</div>
<div class="value">{{ sessionData.user.name }}</div>
<div class="label">Email:</div>
<div class="value">{{ sessionData.user.email }}</div>
<div class="label">Roles:</div>
<div class="value">
{{ sessionData.user.roles || "authenticated" }}
</div>
<div class="label">Status:</div>
<div class="value">{{ sessionData.status }}</div>
</div>
</div>
<!-- OAuth & Token Metadata -->
<div class="section">
<h3 class="section-title">OAuth & Token Metadata</h3>
<div class="info-grid">
<div class="label">Subject (User ID):</div>
<div class="value">
{{ sessionData.accessTokenPayload?.sub || "N/A" }}
</div>
<div class="label">Issuer:</div>
<div class="value">
{{ sessionData.accessTokenPayload?.iss || "N/A" }}
</div>
<div class="label">Audience:</div>
<div class="value">
{{ sessionData.accessTokenPayload?.aud || "N/A" }}
</div>
<div class="label">Token Issued At:</div>
<div class="value">{{ tokenIssuedAt }}</div>
<div class="label">Token Expires At:</div>
<div class="value">{{ tokenExpiresAt }}</div>
</div>
</div>
<!-- Token Information -->
<div class="section">
<h3 class="section-title">Token Information (after callbacks [])</h3>
<!-- ID Token -->
<div class="token-section">
<div class="token-header">
<h4>ID Token (session.id_token)</h4>
<button
class="copy-btn"
@click="copyToClipboard(sessionData.idToken)"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div
class="token-content"
:class="{ collapsed: !expandedSections.idToken }"
>
{{ sessionData.idToken }}
</div>
<button class="toggle-btn" @click="toggleSection('idToken')">
{{ expandedSections.idToken ? "Show less" : "Show more" }}
</button>
</div>
<!-- Access Token -->
<div class="token-section">
<div class="token-header">
<h4>JWT Token (session.jwt)</h4>
<button
class="copy-btn"
@click="copyToClipboard(sessionData.accessToken)"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div
class="token-content"
:class="{ collapsed: !expandedSections.accessToken }"
>
{{ sessionData.accessToken }}
</div>
<button class="toggle-btn" @click="toggleSection('accessToken')">
{{ expandedSections.accessToken ? "Show less" : "Show more" }}
</button>
</div>
<!-- Refresh Token -->
<div class="token-section" v-if="sessionData.refreshToken">
<div class="token-header">
<h4>Refresh Token (session.refresh_token)</h4>
<button
class="copy-btn"
@click="copyToClipboard(sessionData.refreshToken)"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div
class="token-content"
:class="{ collapsed: !expandedSections.refreshToken }"
>
{{ sessionData.refreshToken }}
</div>
<button class="toggle-btn" @click="toggleSection('refreshToken')">
{{ expandedSections.refreshToken ? "Show less" : "Show more" }}
</button>
</div>
</div>
<!-- Session Timeline -->
<div class="section">
<h3 class="section-title">Session Timeline</h3>
<div class="info-grid">
<div class="label">Session Created:</div>
<div class="value">{{ sessionCreatedDate }}</div>
<div class="label">Session Expires:</div>
<div class="value">{{ sessionExpiresDate }}</div>
<div class="label">Remaining Time:</div>
<div class="value" style="font-weight: 600; color: #2563eb">
{{ remainingTime }}
</div>
<div class="label">Session Scope:</div>
<div class="value">
{{
sessionData.accessTokenPayload?.scope || "openid email profile"
}}
</div>
</div>
</div>
<!-- Full Session Object (Debug) -->
<div class="section payload-section">
<h3 class="section-title">Complete Raw Session Data (Debug)</h3>
<div class="token-header mb-2">
<h4>Full Session Object</h4>
<button
class="copy-btn"
@click="
copyToClipboard(formatJson(sessionData.fullSessionObject))
"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div class="payload-content">
{{ formatJson(sessionData.fullSessionObject) }}
</div>
</div>
<!-- Access Token Payload -->>
<div class="section payload-section">
<h3 class="section-title">Access Token Payload (Parsed data [])</h3>
<!-- Raw Access Token -->
<div class="token-header mb-2">
<h4>Raw Access Token (JWT)</h4>
<button
class="copy-btn"
@click="copyToClipboard(sessionData.accessToken)"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div class="token-content mb-4" style="max-height: 100px;">
{{ sessionData.accessToken }}
</div>
<!-- Parsed Payload -->
<div class="token-header mb-2">
<h4>Parsed Payload (Decoded from JWT)</h4>
<button
class="copy-btn"
@click="
copyToClipboard(formatJson(sessionData.accessTokenPayload))
"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div class="payload-content">
{{ formatJson(sessionData.accessTokenPayload) }}
</div>
</div>
<!-- ID Token Payload -->
<div class="section payload-section">
<h3 class="section-title">ID Token Payload (Parsed data [])</h3>
<!-- Raw ID Token -->
<div class="token-header mb-2">
<h4>Raw ID Token (JWT)</h4>
<button
class="copy-btn"
@click="copyToClipboard(sessionData.idToken)"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div class="token-content mb-4" style="max-height: 100px;">
{{ sessionData.idToken }}
</div>
<!-- Parsed Payload -->
<div class="token-header mb-2">
<h4>Parsed Payload (Decoded from JWT)</h4>
<button
class="copy-btn"
@click="copyToClipboard(formatJson(sessionData.idTokenPayload))"
>
<v-icon size="small">mdi-content-copy</v-icon>
Copy
</button>
</div>
<div class="payload-content">
{{ formatJson(sessionData.idTokenPayload) }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Optional: Basic styling for better visibility */
summary {
list-style: none; /* Removes the default arrow */
display: block; /* Allows summary to span full width */
padding: 0.5rem 0;
}
/* Adds a custom arrow/chevron */
summary::before {
content: "▶";
margin-right: 0.5em;
transition: transform 0.2s;
display: inline-block;
}
details[open] summary::before {
content: "▼";
/* transform: rotate(90deg); */
}
</style>
<style scoped lang="scss">
// Styles are in _dashboard.scss
</style>
+77
View File
@@ -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
};
}
});
+249 -230
View File
@@ -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}`);
}
});
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}`);
}
});
+5 -11
View File
@@ -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)
+9 -12
View File
@@ -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');
+71 -54
View File
@@ -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 ---
+7 -8
View File
@@ -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' }
}
+77
View File
@@ -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()
};
}
});
+69 -41
View File
@@ -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<string, SessionData>();
// 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<SessionData | null> {
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()
}))
};
}
+75
View File
@@ -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,
};
}, {