369 lines
13 KiB
TypeScript
369 lines
13 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, status: sessionStatus } = 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);
|
|
|
|
// 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 && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Only render iframe when we have a src to avoid browser warning */}
|
|
{iframeSrc && (
|
|
<iframe
|
|
ref={iframeRef}
|
|
id="myFrame"
|
|
src={iframeSrc}
|
|
className={`w-full border-none ${className}`}
|
|
style={{
|
|
display: 'block',
|
|
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|