import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode, } from 'react'; import { LaminaClient, LaminaAuthError } from '@uselamina/sdk'; import { Button, Card, Flex, Stack, Text, Spinner } from '@sanity/ui'; import type { LaminaPluginOptions } from '../types.js'; import { clearToken, exchangeCode, getStoredToken, prepareOAuthFlow, refreshIfNeeded, storeToken, subscribeToTokenChanges, } from './oauth.js'; import { gcDialogState } from './dialogStore.js'; const LAMINA_ORIGIN = 'https://app.uselamina.ai'; /** * How often to re-check whether the OAuth access token needs a refresh. * 4 min lines up well with the 5-min refresh buffer in `refreshIfNeeded`, * so we always have one timer fire before the access token expires. */ const REFRESH_CHECK_INTERVAL_MS = 4 * 60 * 1000; interface LaminaContextValue { client: LaminaClient; options: LaminaPluginOptions; /** The resolved API key or OAuth access token used by the client. */ token: string; } const Ctx = createContext(null); export function useLamina(): LaminaContextValue { const value = useContext(Ctx); if (!value) { throw new Error( 'useLamina() must be used inside . ' + 'Make sure laminaPlugin() is added to your sanity.config.', ); } return value; } function OAuthLogin({ options, }: { options: LaminaPluginOptions; }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const baseUrl = options.baseUrl || LAMINA_ORIGIN; const handleLogin = useCallback(() => { if (!options.oauth) return; setLoading(true); setError(null); // Open the popup synchronously inside the click handler so the browser // counts it as a user gesture. If we awaited DCR or PKCE work first, the // window.open after that await would be classified as non-user-initiated // and silently blocked by Safari/Firefox/Chrome. We open `about:blank` // first and navigate it once the auth URL is ready. const popup = window.open( 'about:blank', 'lamina-oauth', 'width=600,height=700,popup=yes', ); if (!popup) { setError('Failed to open login popup. Please allow popups for this site.'); setLoading(false); return; } void (async () => { let url: string; try { url = await prepareOAuthFlow(options.oauth!, baseUrl); } catch (err) { popup.close(); setError( err instanceof Error ? err.message : 'Failed to start login flow.', ); setLoading(false); return; } popup.location.href = url; // Listen for the callback. The OAuth callback page is hosted on the // Lamina backend (baseUrl), so messages originate from THERE, not from // the Studio's own origin. const handleMessage = async (event: MessageEvent) => { if (event.origin !== baseUrl) return; if (!event.data?.type || event.data.type !== 'lamina:oauth-callback') return; const code = event.data.code as string | undefined; if (!code) { setError('No authorization code received.'); setLoading(false); return; } try { const tokens = await exchangeCode(options.oauth!, baseUrl, code); // storeToken dispatches the token-change event; useResolvedToken // (via useSyncExternalStore) re-reads localStorage in the parent // and re-renders, replacing this with the real UI. storeToken( options.oauth!, tokens.accessToken, tokens.refreshToken, tokens.expiresIn, ); } catch (err) { setError( err instanceof Error ? err.message : 'Authentication failed.', ); } finally { setLoading(false); } }; window.addEventListener('message', handleMessage); // Also poll for popup close (user closed without completing) const interval = setInterval(() => { if (popup.closed) { clearInterval(interval); window.removeEventListener('message', handleMessage); setLoading(false); } }, 500); })(); }, [options, baseUrl]); return ( Connect to Lamina Sign in with your Lamina account to generate and manage media assets. {error ? ( {error} Look for a popup blocked icon in your browser's address bar and allow popups for this site.