UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

185 lines 6.98 kB
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; const FlavoAuthContext = createContext(null); /** * Resolves the signed-in end-user from the httpOnly session cookie. The * cookie is unreadable to JS, so the server is the only source of truth — * there is no client-side fallback. */ async function fetchCurrentUser() { try { const response = await fetch('/api/auth/me', { credentials: 'include', }); if (response.ok) { const { user } = await response.json(); return user; } } catch (_err) { // /api/auth/me unreachable — treat the session as signed out. } return null; } /** * Hook to access Flavo auth state and actions. * Must be used within a FlavoAuthProvider. * * @example * function MyComponent() { * const { user, isAuthenticated, login, logout } = useFlavoAuth(); * if (!isAuthenticated) return <button onClick={login}>Sign in</button>; * return <div>Welcome, {user?.displayName}! <button onClick={logout}>Sign out</button></div>; * } */ export function useFlavoAuth() { const context = useContext(FlavoAuthContext); if (!context) { throw new Error('useFlavoAuth must be used within a FlavoAuthProvider or FastfoldProvider with flavoAuth enabled.'); } return context; } /** * Context provider that manages the Flavo auth lifecycle: * - Resolves the session from the httpOnly cookie on mount (via `/api/auth/me`) * - Provides login/logout actions * * @example * <FlavoAuthProvider> * <App /> * </FlavoAuthProvider> */ export function FlavoAuthProvider({ children, config, }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { const init = async () => { setIsLoading(true); const currentUser = await fetchCurrentUser(); if (currentUser) { setUser(currentUser); config?.onLogin?.(currentUser); } setIsLoading(false); }; init(); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Expired-refresh dispatches `flavo:auth-expired` globally so the UI // flips to signed-out even when the failure originates from a // background fetch outside this component tree. useEffect(() => { if (typeof window === 'undefined') return; const onAuthExpired = () => { setUser(null); config?.onLogout?.(); }; window.addEventListener('flavo:auth-expired', onAuthExpired); return () => window.removeEventListener('flavo:auth-expired', onAuthExpired); }, [config?.onLogout]); const login = useCallback(() => { if (config?.appId) { const base = (config.loginBaseUrl || 'https://flavo.ai').replace(/\/$/, ''); window.location.href = `${base}/api/app/auth/login/${encodeURIComponent(config.appId)}`; } else { // Dedicated container mode routes through the local Fastfold server // so FLAVO_APP_TOKEN stays server-side and never touches the browser. window.location.href = '/api/auth/login'; } }, [config?.appId, config?.loginBaseUrl]); const logout = useCallback(async () => { setUser(null); // Fire-and-forget: UI must flip to signed-out immediately even if the // server revoke call is unreachable, so local state is cleared first. try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', headers: { 'X-Requested-With': 'fetch' }, }); } catch (_err) { // Revoke endpoint unreachable; local state is already cleared above. } config?.onLogout?.(); }, [config?.onLogout]); const value = useMemo(() => ({ user, isLoading, // The session JWT lives in an httpOnly cookie the client never sees, so // authentication is gated on a resolved `user`, not on any client token. isAuthenticated: !!user, login, logout, }), [user, isLoading, login, logout]); return React.createElement(FlavoAuthContext.Provider, { value }, children); } /** * Drop-in component for the OAuth callback route (/auth). Waits for the * provider to resolve the session cookie, then redirects to `redirectTo` on * success or `/login?error=auth_failed` on failure. * * Renders `loadingComponent` (or nothing, by default) while the session is * being validated. Style it to match the app's own theme so the transient * paint looks native to the app. * * @example * // In your router: * <Route path="/auth" element={<FlavoAuthCallback redirectTo="/app" loadingComponent={<AppSkeleton />} />} /> */ export function FlavoAuthCallback({ redirectTo = '/', loadingComponent, }) { const { isLoading, isAuthenticated } = useFlavoAuth(); useEffect(() => { if (!isLoading) { if (isAuthenticated) { window.location.href = redirectTo; } else { window.location.href = '/login?error=auth_failed'; } } }, [isLoading, isAuthenticated, redirectTo]); return (loadingComponent ?? null); } /** * Wraps children and only renders them when the user is authenticated. * * While the session is being validated, renders `loadingComponent` — or * nothing at all if the caller didn't pass one. Style it to match the app's * own theme; a skeleton that mirrors the authenticated shell usually works * best because the refresh paint becomes a still frame of the real page. * * When unauthenticated with no `fallback`, triggers `login()` and renders * `loadingComponent` (or nothing) while the browser navigates to the Flavo * login page. * * @example * <ProtectedRoute fallback={<LoginPage />} loadingComponent={<AppSkeleton />}> * <Dashboard /> * </ProtectedRoute> * * @example * // Auto-redirect to login when unauthenticated * <ProtectedRoute loadingComponent={<AppSkeleton />}> * <Dashboard /> * </ProtectedRoute> */ export function ProtectedRoute({ children, fallback, loadingComponent, }) { const { isLoading, isAuthenticated, login } = useFlavoAuth(); if (isLoading) { return (loadingComponent ?? null); } if (!isAuthenticated) { if (fallback) { return fallback; } // Fire the redirect synchronously during render: there is no fallback // to show, so deferring to useEffect would leave an empty frame before // the navigation starts. The return paints loadingComponent for that // frame so the user sees the app's own loading UI, not a blank screen. login(); return (loadingComponent ?? null); } return children; } //# sourceMappingURL=auth.js.map