##update fix session dan tampilan screen

This commit is contained in:
Fanrouver
2026-01-30 15:11:17 +07:00
parent 507f415710
commit 8dd94ed744
8 changed files with 298 additions and 384 deletions
+29 -11
View File
@@ -10,6 +10,7 @@ export interface WebSocketConfig {
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
}
export interface WebSocketMessage {
@@ -24,7 +25,18 @@ export const useWebSocket = (config: WebSocketConfig) => {
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const wsUrl = computed(() => {
const url = new URL(config.url)
let baseUrl = config.url
// Automatically upgrade to secure protocols if caller uses HTTPS
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'wss://')
} else if (baseUrl.startsWith('http://')) {
baseUrl = baseUrl.replace('http://', 'https://')
}
}
const url = new URL(baseUrl)
url.searchParams.set('client_id', config.clientId)
return url.toString()
})
@@ -106,17 +118,23 @@ export const useWebSocket = (config: WebSocketConfig) => {
// Send data via POST to WebSocket server
const sendViaPost = async (message: WebSocketMessage) => {
try {
// Extract base URL from WebSocket URL (convert ws:// to http:// or wss:// to https://)
let baseUrl = config.url
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
let postUrl = ''
if (config.fallbackPostUrl) {
// Use the explicitly provided fallback URL (likely a relative proxy path)
postUrl = config.fallbackPostUrl
} else {
// Derive post URL from WebSocket URL (legacy behavior)
let baseUrl = config.url
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
}
const urlObj = new URL(baseUrl)
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
}
// Remove query parameters and ensure we have the correct POST endpoint
const urlObj = new URL(baseUrl)
const postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
console.log('📡 POST URL:', postUrl)
console.log('📦 POST Body:', JSON.stringify(message, null, 2))
+1
View File
@@ -920,6 +920,7 @@ const adminClientId = `admin-klinik-ruang-${kodeKlinik.value}`;
const { sendViaPost } = useWebSocket({
url: wsBaseUrl,
clientId: adminClientId,
fallbackPostUrl: '/stats-api/ws'
});
const showSnackbar = (message, color = 'success') => {
+119 -371
View File
@@ -1,6 +1,5 @@
<template>
<div class="scaler-wrapper">
<div class="antrian-display-container">
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
@@ -17,7 +16,7 @@
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK RUANG</h1>
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
</div>
</div>
</div>
<div class="header-right">
<div class="datetime-display">
@@ -38,11 +37,12 @@
</div>
<!-- Main Grid - Display Ruang -->
<div class="clinics-grid">
<div class="clinics-grid" :style="gridStyle">
<div
v-for="ruang in displayedRuang"
:key="`${ruang.kodeKlinik}-${ruang.nomorRuang}`"
class="clinic-box"
:class="{ 'is-single-view': displayedRuang.length === 1 }"
>
<!-- Clinic Header -->
<div class="clinic-header-bar">
@@ -133,7 +133,6 @@
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -147,6 +146,16 @@ definePageMeta({
layout: false,
})
// Set viewport meta tag for proper responsive behavior
useHead({
meta: [
{
name: 'viewport',
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
}
]
})
const route = useRoute()
const queueStore = useQueueStore()
const masterStore = useMasterStore()
@@ -290,46 +299,27 @@ const displayedRuang = computed(() => {
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
if (priorityDiff !== 0) return priorityDiff
// Sort by queue number
const numA = parseInt(a.noAntrian.match(/\d+/)?.[0] || '999')
const numB = parseInt(b.noAntrian.match(/\d+/)?.[0] || '999')
const numA = parseInt(a.noAntrian.replace(/\D/g, '') || '999')
const numB = parseInt(b.noAntrian.replace(/\D/g, '') || '999')
return numA - numB
})
// Split by tipeLayanan berdasarkan tipeLayanan terakhir yang dipanggil
// Pasien yang dipanggil akan muncul di kolom sesuai tipeLayanan terakhir yang dipanggil
// Jika pasien dipanggil untuk Pemeriksaan Awal, muncul di kolom Pemeriksaan Awal
// Jika pasien dipanggil untuk Tindakan, muncul di kolom Tindakan (dan hilang dari Pemeriksaan Awal)
const pemeriksaanAwalQueues = allQueues.filter(q => {
// Gunakan tipeLayanan (yang diupdate saat dipanggil) untuk menentukan kolom
return q.tipeLayanan === 'Pemeriksaan Awal';
});
const tindakanQueues = allQueues.filter(q => {
// Gunakan tipeLayanan (yang diupdate saat dipanggil) untuk menentukan kolom
return q.tipeLayanan === 'Tindakan';
})
const pemeriksaanAwalQueues = allQueues.filter(q => q.tipeLayanan === 'Pemeriksaan Awal')
const tindakanQueues = allQueues.filter(q => q.tipeLayanan === 'Tindakan')
// Get current and next queues for each tipeLayanan
// Current queue is the one with status 'di-loket' and matching tipeLayanan
// Jika ada pasien baru yang dipanggil, akan menggantikan pasien sebelumnya di kolom tersebut
const getCurrentAndNext = (queues, tipeLayanan) => {
// Find current: pasien dengan status 'di-loket' dan tipeLayanan yang sesuai
// Sort by lastCalledAt untuk mendapatkan yang terakhir dipanggil (yang baru dipanggil akan menggantikan yang lama)
const currentCandidates = queues.filter(q =>
q.status === 'di-loket' && q.tipeLayanan === tipeLayanan
).sort((a, b) => {
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt) : new Date(a.createdAt || 0)
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt) : new Date(b.createdAt || 0)
return timeB - timeA // Yang terakhir dipanggil di atas
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt).getTime() : 0
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt).getTime() : 0
return timeB - timeA
})
// Ambil yang terakhir dipanggil sebagai current (menggantikan yang sebelumnya)
const current = currentCandidates.length > 0 ? currentCandidates[0] : null
// Next queues: exclude current patient dan pasien dengan status 'di-loket' (yang sudah dipanggil)
const current = currentCandidates.length > 0 ? currentCandidates[0] : null
const next = queues.filter(q =>
q.no !== current?.no &&
q.status === 'waiting' // Hanya yang waiting (belum dipanggil)
).slice(0, 3) // Show max 3 next queues
q.no !== current?.no && q.status === 'waiting'
).slice(0, 3)
return { current, next }
}
@@ -394,6 +384,19 @@ const statistics = computed(() => {
}
})
// Dynamic grid logic: divide total by 2 to balance across rows, cap at 5 for larger cards
// Meniru logika AntrianLoket
const gridColumns = computed(() => {
const total = displayedRuang.value.length
if (total <= 1) return 1
if (total <= 5) return total
return Math.min(5, Math.ceil(total / 2))
})
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`
}))
const updateTime = () => {
const now = new Date()
// Format jam dengan titik dua (HH:MM:SS)
@@ -460,9 +463,10 @@ const initWebSocket = () => {
console.log('🆔 Client ID:', anjunganClientId.value)
wsInstance = useWebSocket({
url: wsBaseUrl,
clientId: anjunganClientId.value,
onMessage: (data) => {
url: wsBaseUrl,
clientId: anjunganClientId.value,
fallbackPostUrl: '/stats-api/ws',
onMessage: (data) => {
console.log('📨 WebSocket raw message received:', data)
console.log('📨 Message type:', typeof data)
@@ -562,33 +566,6 @@ const initWebSocket = () => {
return wsInstance
}
// Responsive scaling function
const updateScale = () => {
const container = document.querySelector('.antrian-display-container')
if (!container) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const baseWidth = 1920
const baseHeight = 1080
// Calculate scale factors
const scaleX = viewportWidth / baseWidth
const scaleY = viewportHeight / baseHeight
// Use smaller scale to fit both dimensions
const scale = Math.min(scaleX, scaleY)
// Apply scale using CSS variable
container.style.setProperty('--scale-factor', scale.toString())
// Adjust container dimensions for smaller screens
if (viewportWidth < baseWidth || viewportHeight < baseHeight) {
container.style.width = `${baseWidth}px`
container.style.height = `${baseHeight}px`
}
}
onMounted(() => {
// Redirect to index if klinik not found
if (!klinikData.value) {
@@ -607,10 +584,6 @@ onMounted(() => {
console.log('🚀 WebSocket connection initiated')
}
}
// Setup responsive scaling
updateScale()
window.addEventListener('resize', updateScale)
})
onUnmounted(() => {
@@ -619,7 +592,6 @@ onUnmounted(() => {
wsInstance.disconnect()
console.log('🔌 WebSocket disconnected')
}
window.removeEventListener('resize', updateScale)
})
// Watch for clientId changes and reconnect if needed
@@ -636,36 +608,16 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
</script>
<style scoped lang="scss">
.scaler-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-neutral-300);
overflow: hidden;
z-index: 1;
}
.antrian-display-container {
background: var(--color-neutral-300);
width: 1920px;
height: 1080px;
width: 100vw;
height: 100vh;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
flex-shrink: 0; /* Prevent flex from shrinking the fixed-size container */
/* Responsive scaling menggunakan CSS variables dan JavaScript */
transform-origin: center center;
--scale-factor: 1;
transform: scale(var(--scale-factor));
}
/* ========== HEADER ========== */
@@ -723,14 +675,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.datetime-display {
text-align: right;
}
@@ -796,10 +740,11 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
/* ========== CLINICS GRID ========== */
.clinics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* grid-template-columns set via inline style */
gap: 16px;
margin-bottom: 20px;
flex: 1;
min-height: 0; /* Allow ensuring internal scroll if needed, but we aim for fit */
}
.clinic-box {
@@ -808,7 +753,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 240px;
display: flex;
flex-direction: column;
}
@@ -822,7 +766,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
}
.clinic-title {
font-size: 22px;
font-size: clamp(14px, 1.15vw, 22px);
font-weight: 800;
color: var(--color-neutral-100);
letter-spacing: 0.5px;
@@ -860,13 +804,13 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
}
.tipe-label {
font-size: 20px;
font-size: clamp(14px, 1.04vw, 20px);
font-weight: 700;
color: var(--color-primary-700);
margin-bottom: 12px;
letter-spacing: 1px;
text-transform: uppercase;
padding: 8px 12px;
padding: clamp(4px, 0.42vw, 8px) clamp(8px, 0.63vw, 12px);
background: var(--color-primary-200);
border-radius: 8px;
display: inline-block;
@@ -899,7 +843,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
}
.current-number {
font-size: 80px;
font-size: clamp(48px, 4.17vw, 80px);
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 4px;
@@ -907,42 +851,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.next-section {
text-align: center;
margin-top: 8px;
}
.next-label {
font-size: 13px;
font-weight: 700;
color: var(--color-neutral-700);
margin-bottom: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.next-numbers {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.next-item {
background: var(--color-primary-600);
color: var(--color-neutral-100);
padding: 8px 16px;
border-radius: 12px;
font-size: 22px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.25);
letter-spacing: 1px;
min-width: 60px;
text-align: center;
}
// Empty state removed - only show in individual columns
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: var(--color-neutral-100);
@@ -971,10 +879,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
}
.stat-icon-total,
.stat-icon-waiting {
background: var(--color-primary-200);
}
.stat-icon-waiting,
.stat-icon-active {
background: var(--color-primary-200);
}
@@ -1016,120 +921,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
justify-content: flex-end;
}
.ws-status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
margin-left: 16px;
}
.ws-status-indicator.ws-connected {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
.ws-status-indicator.ws-disconnected {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
}
.ws-status-text {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ========== RESPONSIVE ========== */
// Base responsive rules untuk komponen individual
@media (max-width: 1919px) or (max-height: 1079px) {
// Adjust font sizes and spacing secara proporsional
.display-header {
padding: clamp(12px, 1.25vw, 24px) clamp(20px, 2.08vw, 40px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.logo-circle {
width: clamp(48px, 4.17vw, 80px);
height: clamp(48px, 4.17vw, 80px);
}
.hospital-name {
font-size: clamp(28px, 2.5vw, 48px);
}
.display-subtitle {
font-size: clamp(14px, 1.04vw, 20px);
}
.time-large {
font-size: clamp(36px, 2.92vw, 56px);
}
.date-small {
font-size: clamp(12px, 0.94vw, 18px);
}
.hero-call-section {
padding: clamp(16px, 1.67vw, 32px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.call-label {
font-size: clamp(20px, 1.46vw, 28px);
}
.call-number {
font-size: clamp(80px, 7.29vw, 140px);
}
.call-clinic {
font-size: clamp(24px, 1.88vw, 36px);
}
.clinics-grid {
gap: clamp(8px, 0.83vw, 16px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.clinic-header-bar {
padding: clamp(10px, 1.04vw, 16px) clamp(12px, 1.04vw, 20px);
}
.clinic-title {
font-size: clamp(16px, 1.15vw, 22px);
}
.clinic-content {
padding: clamp(12px, 1.04vw, 20px);
}
.current-number {
font-size: clamp(56px, 4.17vw, 80px);
}
.tipe-label {
font-size: clamp(16px, 1.04vw, 20px);
padding: clamp(6px, 0.42vw, 8px) clamp(10px, 0.63vw, 12px);
}
.footer-stats-bar {
padding: clamp(12px, 1.04vw, 20px) clamp(24px, 2.08vw, 40px);
gap: clamp(16px, 1.67vw, 32px);
}
.stat-value {
font-size: clamp(28px, 2.08vw, 40px);
}
.stat-label {
font-size: clamp(11px, 0.73vw, 14px);
}
}
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
@@ -1144,104 +936,24 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-clinic {
font-size: 28px;
}
.clinics-grid {
gap: 12px;
}
.clinic-box {
min-height: 220px;
padding: 0;
}
.clinic-header-bar {
padding: 14px 16px;
}
.clinic-title {
font-size: 20px;
}
.clinic-content {
padding: 16px;
}
.current-number {
font-size: 72px;
}
.tipe-label {
font-size: 18px;
padding: 6px 10px;
}
.next-item {
font-size: 20px;
padding: 6px 14px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
font-size: 60px;
}
}
@@ -1251,57 +963,31 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.clinics-grid {
grid-template-columns: repeat(2, 1fr);
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 768px) {
.clinics-grid {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.call-number {
font-size: 80px;
}
.current-number {
font-size: 60px;
}
}
/* Animations */
@keyframes pulse-highlight {
0%, 100% {
@@ -1327,5 +1013,67 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Single View Optimizations */
.clinic-box.is-single-view {
display: flex;
flex-direction: column;
}
.clinic-box.is-single-view .clinic-header-bar {
padding: 32px 40px;
}
.clinic-box.is-single-view .clinic-title {
font-size: 56px; /* Massive title */
text-align: center;
}
.clinic-box.is-single-view .clinic-count {
padding: 8px 16px;
}
.clinic-box.is-single-view .count-text {
font-size: 24px;
}
.clinic-box.is-single-view .clinic-content {
padding: 40px;
justify-content: space-evenly; /* Distribute vertical space */
}
.clinic-box.is-single-view .tipe-layanan-section {
display: flex;
flex-direction: column;
align-items: center;
flex: 1; /* Take up available space */
justify-content: center;
}
.clinic-box.is-single-view .tipe-label {
font-size: 32px; /* Much larger label */
padding: 12px 24px;
margin-bottom: 24px;
}
.clinic-box.is-single-view .current-label {
font-size: 24px;
margin-bottom: 16px;
}
.clinic-box.is-single-view .current-number {
font-size: 180px; /* Enormous number */
line-height: 1;
margin: 20px 0;
}
.clinic-box.is-single-view .empty-text-small {
font-size: 24px;
}
.clinic-box.is-single-view .v-icon {
transform: scale(1.5); /* Scale up icons */
}
</style>
+14 -1
View File
@@ -342,10 +342,23 @@ onMounted(async () => {
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: 24px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 1280px) {
.kliniks-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 960px) {
.kliniks-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.klinik-card {
+14 -1
View File
@@ -252,10 +252,23 @@ const getKlinikName = (kode, loket = null) => {
.lokets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: 24px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 1280px) {
.lokets-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 960px) {
.lokets-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.loket-card {
+1
View File
@@ -49,6 +49,7 @@
{{ errorMessage }}
</v-alert>
<v-alert v-if="successMessage" type="success" class="mb-4">
{{ successMessage }}
</v-alert>
+45
View File
@@ -5,6 +5,51 @@ export default defineEventHandler(async (event) => {
console.log('🔐 Keycloak Login Handler Called')
console.log('📍 Method:', getMethod(event))
// === STALE SESSION CLEANUP ===
// Check for existing session and clean up if invalid/expired
const existingSessionId = getCookie(event, 'user_session')
if (existingSessionId) {
console.log('🔍 Existing session cookie found, validating...')
try {
const { getSession, deleteSession } = await import('~/server/utils/sessionStore')
const session = getSession(existingSessionId)
if (session) {
// Check if session is expired
const isExpired = Date.now() > session.expiresAt
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)
// Clear cookies anyway to be safe
deleteCookie(event, 'user_session')
deleteCookie(event, 'oauth_state')
}
} else {
console.log('️ No existing session found, proceeding with fresh login')
}
// === END STALE SESSION CLEANUP ===
try {
const config = useRuntimeConfig()
+75
View File
@@ -0,0 +1,75 @@
// server/api/auth/validate-session.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const sessionId = getCookie(event, 'user_session')
console.log('🔍 Session validation endpoint called')
console.log('🍪 Session cookie exists:', !!sessionId)
if (!sessionId) {
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)
if (!session) {
console.log('❌ Session not found in store')
deleteCookie(event, 'user_session')
return { valid: false, reason: 'session_not_found' }
}
// 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' }
}
// Validate with Keycloak userinfo endpoint
try {
const userInfoUrl = `${config.keycloakIssuer}/protocol/openid-connect/userinfo`
console.log('🔗 Validating with Keycloak:', userInfoUrl)
const userInfo = await $fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${session.accessToken}`
}
})
console.log('✅ Session is valid with Keycloak')
return {
valid: true,
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name
}
}
} catch (keycloakError: any) {
// 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' }
}
// For other errors, log but don't invalidate the session
console.warn('⚠️ Keycloak validation error (non-401):', keycloakError.message)
throw keycloakError
}
} catch (error: any) {
console.error('❌ Session validation error:', error)
// On error, clear the session to be safe
deleteCookie(event, 'user_session')
return {
valid: false,
reason: 'validation_error',
error: error.message
}
}
})