push update socket data dan token
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
@use "./variables" as *;
|
||||
@use "./colors" as *;
|
||||
@use "./typography" as *;
|
||||
@use "./pages/dashboard" as *;
|
||||
|
||||
// Global styles
|
||||
*,
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ---
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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()
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user