import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub'; const AuthContext = createContext(null); const HF_API = 'https://huggingface.co'; // Whether we're running inside an iframe (i.e. embedded on huggingface.co) const isInIframe = typeof window !== 'undefined' && window.parent !== window; /** * Fetch OAuth config (clientId, scopes) from our Express server. * Docker Spaces don't auto-inject window.huggingface.variables like static Spaces, * so we expose OAUTH_CLIENT_ID via a server endpoint. */ async function fetchOAuthConfig() { try { const response = await fetch('/api/oauth-config'); if (!response.ok) return null; return await response.json(); } catch { return null; } } /** * Fetch all space IDs liked by a user. * Returns a Set of lowercase space IDs (e.g. "pollen-robotics/reachy-mini-teleop"). */ async function fetchUserLikedSpaces(username) { try { const response = await fetch(`${HF_API}/api/users/${username}/likes`); if (!response.ok) return new Set(); const likes = await response.json(); // Filter only spaces and return their repo names as a Set return new Set( likes .filter((item) => item.repo?.type === 'space') .map((item) => item.repo.name.toLowerCase()) ); } catch (err) { console.error('[Auth] Failed to fetch user likes:', err); return new Set(); } } /** * Send a like request to the HF parent frame via postMessage. * Returns a Promise that resolves with the response data. * The parent frame (huggingface.co) handles auth via session cookies. */ function likeViaPostMessage(spaceId) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Like request timed out')); }, 5000); function handler(event) { if (event.data?.type !== 'LIKE_REPO_RESPONSE') return; clearTimeout(timeout); window.removeEventListener('message', handler); resolve(event.data); } window.addEventListener('message', handler); window.parent.postMessage( { type: 'LIKE_REPO_REQUEST', repo: { type: 'space', name: spaceId } }, '*' ); }); } // Provider component export function AuthProvider({ children }) { const [user, setUser] = useState(null); // { name, avatarUrl } const [likedSpaceIds, setLikedSpaceIds] = useState(new Set()); const [isLoading, setIsLoading] = useState(true); const [oauthConfig, setOauthConfig] = useState(null); // { clientId, scopes } const pendingLikes = useRef(new Set()); // Track in-flight like requests // On mount: fetch OAuth config + check if user just completed OAuth redirect useEffect(() => { async function init() { try { // Fetch OAuth config from server (needed for Docker Spaces) const config = await fetchOAuthConfig(); if (config?.clientId) { setOauthConfig(config); console.log('[Auth] OAuth config loaded (clientId available)'); } else { console.log('[Auth] OAuth not available (no clientId from server)'); } // Check if user just completed OAuth redirect const oauthResult = await oauthHandleRedirectIfPresent(); if (oauthResult) { const { userInfo } = oauthResult; const userData = { name: userInfo.name, preferredUsername: userInfo.preferred_username || userInfo.name, avatarUrl: userInfo.picture, }; setUser(userData); // Fetch user's liked spaces const likes = await fetchUserLikedSpaces(userData.preferredUsername); setLikedSpaceIds(likes); console.log( `[Auth] Logged in as ${userData.preferredUsername}, ${likes.size} liked spaces` ); } } catch (err) { console.error('[Auth] Init error:', err); } finally { setIsLoading(false); } } init(); }, []); // Login: redirect to HF OAuth (passing clientId from server config) const login = useCallback(async () => { if (!oauthConfig?.clientId) { console.warn('[Auth] Cannot login: OAuth not configured'); return; } try { const loginUrl = await oauthLoginUrl({ clientId: oauthConfig.clientId, scopes: oauthConfig.scopes || 'openid profile', }); window.location.href = loginUrl; } catch (err) { console.error('[Auth] Failed to get OAuth URL:', err); } }, [oauthConfig]); // Logout: clear state const logout = useCallback(() => { setUser(null); setLikedSpaceIds(new Set()); }, []); // Check if a space is liked const isSpaceLiked = useCallback( (spaceId) => { return likedSpaceIds.has(spaceId?.toLowerCase()); }, [likedSpaceIds] ); /** * Like a space via the HF parent frame postMessage protocol. * The parent (huggingface.co) handles auth via session cookies. * Like-only (no unlike) — if already liked, it's a no-op. */ const toggleLike = useCallback( async (spaceId) => { const spaceIdLower = spaceId?.toLowerCase(); if (!spaceIdLower) return; // Already liked → no-op (postMessage only supports like, not unlike) if (likedSpaceIds.has(spaceIdLower)) return; // Not in iframe → can't use postMessage, prompt OAuth login as fallback if (!isInIframe) { console.warn('[Auth] Not in iframe, postMessage unavailable'); return; } // Prevent duplicate requests if (pendingLikes.current.has(spaceIdLower)) return; pendingLikes.current.add(spaceIdLower); // Optimistic update setLikedSpaceIds((prev) => { const next = new Set(prev); next.add(spaceIdLower); return next; }); try { const result = await likeViaPostMessage(spaceId); if (result.error) { throw new Error(`${result.error.code}: ${result.error.message}`); } if (result.status === 'not_logged_in') { // User not logged in to HF → revert and prompt login throw new Error('not_logged_in'); } // "done" or "already_liked" → success console.log(`[Auth] Liked ${spaceId}: ${result.status}`, result.likes != null ? `(${result.likes} likes)` : ''); } catch (err) { console.error(`[Auth] Failed to like ${spaceId}:`, err.message); // Revert optimistic update setLikedSpaceIds((prev) => { const reverted = new Set(prev); reverted.delete(spaceIdLower); return reverted; }); // If not logged in, prompt OAuth login if (err.message === 'not_logged_in') { login(); } } finally { pendingLikes.current.delete(spaceIdLower); } }, [likedSpaceIds, login] ); return ( {children} ); } // Hook to use auth context export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }