diff --git a/composables/useWebSocket.ts b/composables/useWebSocket.ts index 12bdeb9..cca7294 100644 --- a/composables/useWebSocket.ts +++ b/composables/useWebSocket.ts @@ -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(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)) diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue index a5e7ac8..29e9d7f 100644 --- a/pages/AdminKlinikRuang/[kodeKlinik].vue +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -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') => { diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index 48cc124..be7d8c5 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -1,6 +1,5 @@ diff --git a/pages/Anjungan/AntrianKlinikRuang/index.vue b/pages/Anjungan/AntrianKlinikRuang/index.vue index 7bdd993..f08d1ac 100644 --- a/pages/Anjungan/AntrianKlinikRuang/index.vue +++ b/pages/Anjungan/AntrianKlinikRuang/index.vue @@ -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 { diff --git a/pages/Anjungan/AntrianLoket/index.vue b/pages/Anjungan/AntrianLoket/index.vue index 3bc343f..e205299 100644 --- a/pages/Anjungan/AntrianLoket/index.vue +++ b/pages/Anjungan/AntrianLoket/index.vue @@ -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 { diff --git a/pages/LoginPage.vue b/pages/LoginPage.vue index 34be9a3..ab43d3f 100644 --- a/pages/LoginPage.vue +++ b/pages/LoginPage.vue @@ -49,6 +49,7 @@ {{ errorMessage }} + {{ successMessage }} diff --git a/server/api/auth/keycloak-login.ts b/server/api/auth/keycloak-login.ts index 3eeb494..ed7633b 100644 --- a/server/api/auth/keycloak-login.ts +++ b/server/api/auth/keycloak-login.ts @@ -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() diff --git a/server/api/auth/validate-session.post.ts b/server/api/auth/validate-session.post.ts new file mode 100644 index 0000000..7b759c1 --- /dev/null +++ b/server/api/auth/validate-session.post.ts @@ -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 + } + } +})