'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useSession } from 'next-auth/react'; interface ResponsiveIframeProps { src: string; className?: string; allow?: string; style?: React.CSSProperties; } // Known service domains for postMessage authentication const SSO_SERVICE_DOMAINS: Record = { 'espace.slm-lab.net': 'nextcloud', 'parole.slm-lab.net': 'rocketchat', 'apprendre.slm-lab.net': 'moodle', 'artlab.slm-lab.net': 'penpot', 'alma.slm-lab.net': 'openwebui', 'agilite.slm-lab.net': 'leantime', 'chapitre.slm-lab.net': 'bookstack', 'vision.slm-lab.net': 'jitsi', 'lemessage.slm-lab.net': 'listmonk', }; /** * Detect service name from URL */ function detectServiceFromUrl(url: string): string | undefined { try { const urlObj = new URL(url); return SSO_SERVICE_DOMAINS[urlObj.hostname]; } catch { return undefined; } } export function ResponsiveIframe({ src, className = '', allow, style, }: ResponsiveIframeProps) { const iframeRef = useRef(null); const { data: session, status: sessionStatus } = useSession(); const [isRefreshing, setIsRefreshing] = useState(false); const [iframeSrc, setIframeSrc] = useState(''); const [hasTriedRefresh, setHasTriedRefresh] = useState(false); // Detect service name from URL for postMessage auth const serviceName = detectServiceFromUrl(src); // Debug logging on mount and state changes useEffect(() => { console.log('[ResponsiveIframe] State:', { src: src || '(empty)', iframeSrc: iframeSrc || '(empty)', sessionStatus, hasSession: !!session, hasAccessToken: !!session?.accessToken, hasRefreshToken: !!session?.refreshToken, hasTriedRefresh, isRefreshing, serviceName: serviceName || '(unknown)', }); }, [src, iframeSrc, session, sessionStatus, hasTriedRefresh, isRefreshing, serviceName]); // Refresh NextAuth session before loading iframe useEffect(() => { console.log('[ResponsiveIframe] Effect triggered:', { src: src || '(empty)', sessionStatus, hasTriedRefresh, }); // If no src, nothing to do if (!src) { console.warn('[ResponsiveIframe] No src provided, cannot load iframe'); return; } // Check if user just logged out - prevent refresh if logout is in progress const justLoggedOut = sessionStorage.getItem('just_logged_out') === 'true'; const logoutCookie = document.cookie.split(';').some(c => c.trim().startsWith('logout_in_progress=true')); if (justLoggedOut || logoutCookie) { console.warn('[ResponsiveIframe] Logout in progress, redirecting to sign-in'); window.location.href = '/signin'; return; } // If session is loading, wait if (sessionStatus === 'loading') { console.log('[ResponsiveIframe] Session still loading, waiting...'); return; } // If no session yet, wait for it (don't set src yet) if (!session) { console.log('[ResponsiveIframe] No session yet, waiting...'); return; } // If already tried refresh for this src, just set it if (hasTriedRefresh) { if (!iframeSrc) { console.log('[ResponsiveIframe] Already tried refresh, setting src directly:', src); setIframeSrc(src); } return; } // Ensure session has required tokens before proceeding if (!session.accessToken || !session.refreshToken) { console.warn('[ResponsiveIframe] Session missing tokens:', { hasAccessToken: !!session.accessToken, hasRefreshToken: !!session.refreshToken, }); console.warn('[ResponsiveIframe] Redirecting to sign-in'); window.location.href = '/signin'; return; } const refreshSession = async () => { console.log('[ResponsiveIframe] Starting session refresh...'); setIsRefreshing(true); setHasTriedRefresh(true); try { // Wait a bit to ensure NextAuth session is fully established await new Promise(resolve => setTimeout(resolve, 100)); // Double-check logout flag before making the request const stillLoggedOut = sessionStorage.getItem('just_logged_out') === 'true'; if (stillLoggedOut) { console.warn('[ResponsiveIframe] Logout detected during refresh, aborting'); window.location.href = '/signin'; return; } // Call our API to refresh the Keycloak session console.log('[ResponsiveIframe] Calling refresh-keycloak-session API...'); const response = await fetch('/api/auth/refresh-keycloak-session', { method: 'GET', credentials: 'include', }); console.log('[ResponsiveIframe] Refresh API response:', { ok: response.ok, status: response.status, }); if (response.ok) { console.log('[ResponsiveIframe] Session refreshed, setting iframe src:', src); setIframeSrc(src); } else { const errorData = await response.json().catch(() => ({})); console.warn('[ResponsiveIframe] Refresh failed:', errorData); // If session was invalidated, redirect to sign-in if (response.status === 401 && errorData.error === 'SessionInvalidated') { console.warn('[ResponsiveIframe] Session invalidated, redirecting to sign-in'); window.location.href = '/signin'; return; } console.warn('[ResponsiveIframe] Loading iframe anyway (may require login)'); setIframeSrc(src); } } catch (error) { console.error('[ResponsiveIframe] Error during refresh:', error); // On error, still try to load iframe with original URL console.log('[ResponsiveIframe] Setting src despite error:', src); setIframeSrc(src); } finally { setIsRefreshing(false); console.log('[ResponsiveIframe] Refresh complete, isRefreshing:', false); } }; refreshSession(); }, [session, sessionStatus, src, hasTriedRefresh, iframeSrc]); // Listen for messages from iframe applications (logout, auth requests) useEffect(() => { const handleMessage = async (event: MessageEvent) => { // Security: Only accept messages from known iframe origins const trustedOrigins = Object.keys(SSO_SERVICE_DOMAINS).map(domain => `https://${domain}`); const isFromTrustedOrigin = trustedOrigins.some(origin => event.origin.includes(origin.replace('https://', ''))); if (!isFromTrustedOrigin && event.origin !== window.location.origin) { // Log but don't block - some services may have different origins console.debug('[Iframe] Message from unknown origin:', event.origin); } // Check if message is a data object if (event.data && typeof event.data === 'object') { // Handle logout request from iframe if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') { console.log('Received logout request from iframe, triggering dashboard logout'); // Mark logout in progress sessionStorage.setItem('just_logged_out', 'true'); document.cookie = 'logout_in_progress=true; path=/; max-age=60'; // Trigger dashboard logout if (session?.idToken) { const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER; if (keycloakIssuer) { const keycloakLogoutUrl = new URL( `${keycloakIssuer}/protocol/openid-connect/logout` ); keycloakLogoutUrl.searchParams.append( 'post_logout_redirect_uri', window.location.origin + '/signin?logout=true' ); keycloakLogoutUrl.searchParams.append( 'id_token_hint', session.idToken ); window.location.replace(keycloakLogoutUrl.toString()); } } else { // Fallback: redirect to signin window.location.replace('/signin?logout=true'); } } // Handle RocketChat auth request if (event.data.type === 'rocketchat.getAuthToken' || event.data.event === 'call-token-auth') { console.log('[RocketChat] Auth token requested by iframe'); if (session?.accessToken && iframeRef.current?.contentWindow) { // Send auth token to RocketChat iframeRef.current.contentWindow.postMessage({ event: 'login-with-token', loginToken: session.accessToken, }, event.origin); } } // Handle generic SSO auth request from any iframe if (event.data.type === 'SSO_AUTH_REQUEST') { console.log('[SSO] Auth request from iframe:', event.origin); if (session?.accessToken && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage({ type: 'SSO_AUTH_RESPONSE', accessToken: session.accessToken, userId: session.user?.id, email: session.user?.email, username: session.user?.username, }, event.origin); } } } }; window.addEventListener('message', handleMessage); return () => { window.removeEventListener('message', handleMessage); }; }, [session]); useEffect(() => { const iframe = iframeRef.current; if (!iframe || !iframeSrc) return; const calculateHeight = () => { const pageY = (elem: HTMLElement): number => { return elem.offsetParent ? (elem.offsetTop + pageY(elem.offsetParent as HTMLElement)) : elem.offsetTop; }; const height = document.documentElement.clientHeight; const iframeY = pageY(iframe); const newHeight = Math.max(0, height - iframeY); iframe.style.height = `${newHeight}px`; }; const handleHashChange = () => { if (window.location.hash && window.location.hash.length && iframe.src) { const iframeURL = new URL(iframe.src); iframeURL.hash = window.location.hash; iframe.src = iframeURL.toString(); } }; // Initial setup calculateHeight(); handleHashChange(); // Event listeners window.addEventListener('resize', calculateHeight); window.addEventListener('hashchange', handleHashChange); iframe.addEventListener('load', calculateHeight); // Cleanup return () => { window.removeEventListener('resize', calculateHeight); window.removeEventListener('hashchange', handleHashChange); iframe.removeEventListener('load', calculateHeight); }; }, [iframeSrc]); // Send auth token to RocketChat iframe after it loads const handleIframeLoad = useCallback(() => { if (!iframeRef.current || !session?.accessToken) return; const iframe = iframeRef.current; const iframeUrl = iframe.src; // Check if this is RocketChat and send auth token if (serviceName === 'rocketchat' && iframe.contentWindow) { try { console.log('[RocketChat] Sending auth token to iframe'); iframe.contentWindow.postMessage({ externalCommand: 'login-with-token', token: session.accessToken, }, new URL(iframeUrl).origin); } catch (error) { console.warn('[RocketChat] Could not send auth token:', error); } } }, [session?.accessToken, serviceName]); return ( <> {isRefreshing && (

Chargement...

)} {/* Only render iframe when we have a src to avoid browser warning */} {iframeSrc && (