NeahStable/app/components/responsive-iframe.tsx
2026-01-10 12:19:12 +01:00

323 lines
11 KiB
TypeScript

'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<string, string> = {
'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<HTMLIFrameElement>(null);
const { data: session } = useSession();
const [isRefreshing, setIsRefreshing] = useState(false);
const [iframeSrc, setIframeSrc] = useState<string>('');
const [hasTriedRefresh, setHasTriedRefresh] = useState(false);
// Detect service name from URL for postMessage auth
const serviceName = detectServiceFromUrl(src);
// Refresh NextAuth session before loading iframe
useEffect(() => {
// If no src, nothing to do
if (!src) {
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('Logout in progress, redirecting to sign-in instead of refreshing session');
window.location.href = '/signin';
return;
}
// If no session yet, wait for it (don't set src yet)
if (!session) {
return;
}
// If already tried refresh for this src, just set it
if (hasTriedRefresh) {
if (!iframeSrc) {
setIframeSrc(src);
}
return;
}
// Ensure session has required tokens before proceeding
if (!session.accessToken || !session.refreshToken) {
console.warn('Session missing required tokens, redirecting to sign-in');
window.location.href = '/signin';
return;
}
const refreshSession = async () => {
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('Logout detected during refresh, aborting');
window.location.href = '/signin';
return;
}
// Call our API to refresh the Keycloak session
// This ensures tokens are fresh and may help refresh Keycloak session cookies
const response = await fetch('/api/auth/refresh-keycloak-session', {
method: 'GET',
credentials: 'include', // Include cookies
});
if (response.ok) {
console.log('Session refreshed before loading iframe');
setIframeSrc(src);
} else {
const errorData = await response.json().catch(() => ({}));
// If session was invalidated, redirect to sign-in
if (response.status === 401 && errorData.error === 'SessionInvalidated') {
console.warn('Keycloak session invalidated, redirecting to sign-in');
window.location.href = '/signin';
return;
}
console.warn('Failed to refresh session, loading iframe anyway (may require login)');
setIframeSrc(src);
}
} catch (error) {
console.error('Error refreshing session:', error);
// On error, still try to load iframe with original URL
setIframeSrc(src);
} finally {
setIsRefreshing(false);
}
};
refreshSession();
}, [session, 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 && (
<div className="flex items-center justify-center w-full h-full absolute bg-black/50 z-10">
<div className="text-center bg-white p-4 rounded-lg shadow-xl">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
)}
<iframe
ref={iframeRef}
id="myFrame"
src={iframeSrc || ''}
className={`w-full border-none ${className}`}
style={{
display: iframeSrc ? 'block' : 'none',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
onLoad={handleIframeLoad}
/>
{/* Show placeholder while waiting for session */}
{!iframeSrc && !isRefreshing && (
<div className="flex items-center justify-center w-full h-full bg-gray-900">
<div className="text-center">
<div className="animate-pulse">
<div className="h-8 w-32 bg-gray-700 rounded mx-auto mb-4"></div>
<div className="h-4 w-48 bg-gray-700 rounded mx-auto"></div>
</div>
</div>
</div>
)}
</>
);
}