diff --git a/api/ai/generate.ts b/api/ai/generate.ts index 626793d..f06adf3 100644 --- a/api/ai/generate.ts +++ b/api/ai/generate.ts @@ -14,13 +14,21 @@ if (SUPABASE_URL && SUPABASE_KEY) { } // Accept multiple env var names so it works on Vercel, local dev, or when the -// key was mistakenly added with a VITE_/NEXT_PUBLIC_ prefix. -const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || +// key was added with a different name. Also allow an OAuth-style bearer token +// (for service accounts or short-lived credentials). If a bearer token is +// provided we will call the endpoint without a ?key= parameter and send +// Authorization: Bearer . +const GEMINI_API_KEY = ( + process.env.GEMINI_API_KEY || process.env.VITE_GEMINI_API_KEY || - process.env.NEXT_PUBLIC_GEMINI_API_KEY) as string | undefined; -const GEMINI_URL = GEMINI_API_KEY - ? `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}` - : null; + process.env.NEXT_PUBLIC_GEMINI_API_KEY || + process.env.GOOGLE_API_KEY || + process.env.GOOGLE_CLOUD_API_KEY || + process.env.GENERATIVE_API_KEY +) as string | undefined; +const GEMINI_BEARER = (process.env.GEMINI_BEARER_TOKEN || process.env.GOOGLE_BEARER_TOKEN) as string | undefined; +const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'; +const GEMINI_URL = GEMINI_BEARER ? GEMINI_BASE : (GEMINI_API_KEY ? `${GEMINI_BASE}?key=${encodeURIComponent(GEMINI_API_KEY)}` : null); // Lightweight CORS helper so static builds and alternate origins can call this function function applyCors(req: any, res: any) { @@ -245,15 +253,33 @@ export default async function handler(req: any, res: any) { // rather than a fabricated plan JSON so the UI shows a clear status. generatedText = 'Sorry, this feature is currently under development.'; } else { - const response = await fetch(GEMINI_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) - }); - const data = await response.json(); - generatedText = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; - if (!generatedText || String(generatedText).trim().length === 0) { - generatedText = 'Sorry, this feature is currently under development.'; + // Call Gemini/Generative API. Support either API key query param or + // Authorization: Bearer header when a bearer token is present. + const headers: any = { 'Content-Type': 'application/json' }; + if (GEMINI_BEARER) headers['Authorization'] = `Bearer ${GEMINI_BEARER}`; + try { + const response = await fetch(GEMINI_URL as string, { + method: 'POST', + headers, + body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) + }); + const text = await response.text(); + let data: any = null; + try { data = JSON.parse(text); } catch (e) { data = null; } + if (!response.ok) { + console.error('[api/ai/generate] Gemini provider returned non-OK', response.status, text); + // Surface a helpful diagnostic to client (non-sensitive) so the UI + // can show a useful message while ops fixes the API keys/config. + return res.status(502).json({ message: 'AI provider error', status: response.status, body: data || text }); + } + generatedText = data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; + if (!generatedText || String(generatedText).trim().length === 0) { + // If provider returned empty text, return a short friendly message + generatedText = 'Sorry, this feature is currently under development.'; + } + } catch (e: any) { + console.error('[api/ai/generate] failed to call Gemini provider', String(e)); + return res.status(502).json({ message: 'AI provider request failed', error: String(e) }); } } diff --git a/src/components/GoogleIdentityButton.tsx b/src/components/GoogleIdentityButton.tsx index 59a5ba4..afe70b1 100644 --- a/src/components/GoogleIdentityButton.tsx +++ b/src/components/GoogleIdentityButton.tsx @@ -31,6 +31,27 @@ const GoogleIdentityButton: React.FC = () => { const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''; const useSupabase = Boolean(import.meta.env.VITE_LOCAL_USE_SUPABASE || import.meta.env.VITE_SUPABASE_URL); + // Quick reachability check for the configured Supabase URL. Returns false + // when the domain doesn't resolve or the request times out. + const checkSupabaseReachable = async (): Promise => { + try { + const rawUrl = (import.meta.env.VITE_SUPABASE_URL && String(import.meta.env.VITE_SUPABASE_URL).trim()) || ''; + if (!rawUrl) return false; + const url = rawUrl.replace(/\/$/, '') + '/auth/v1'; + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), 3000); + try { + await fetch(url, { method: 'GET', signal: controller.signal, cache: 'no-store' }); + clearTimeout(id); + return true; + } finally { + clearTimeout(id); + } + } catch (e) { + return false; + } + }; + // If Supabase is available prefer the Supabase OAuth redirect flow. This // ensures sessions are created/managed by Supabase rather than doing local // ID token verification. @@ -41,6 +62,16 @@ const GoogleIdentityButton: React.FC = () => { className="btn btn-google" onClick={async () => { try { + const reachable = await checkSupabaseReachable(); + if (!reachable) { + window.showFitBuddyNotification?.({ + title: 'Sign-in Unavailable', + message: + 'Google sign-in is temporarily unavailable because the authentication service cannot be reached. Please try again later or contact the site administrator.', + variant: 'warning' + }); + return; + } await signInWithGoogle(); } catch (e) { console.warn('[GoogleIdentityButton] signInWithGoogle failed', e); diff --git a/src/services/authService.ts b/src/services/authService.ts index 3065d19..88fae2a 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -73,6 +73,30 @@ export async function fetchUserById(id: string): Promise { return null; } } + +// Quick reachability test for the configured Supabase URL. We attempt a +// simple fetch to the Supabase auth root so we can detect DNS failures +// or network issues early and fall back to server-side auth endpoints. +async function isSupabaseReachable(): Promise { + try { + const rawUrl = (import.meta.env.VITE_SUPABASE_URL || (process && process.env && process.env.SUPABASE_URL) || ''); + if (!rawUrl) return false; + // Trim trailing slash and hit the auth endpoint; DNS failures surface as fetch errors. + const url = rawUrl.replace(/\/$/, '') + '/auth/v1'; + // Use a short timeout to avoid blocking the UI for long + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), 3000); + try { + await fetch(url, { method: 'GET', signal: controller.signal, cache: 'no-store' }); + clearTimeout(id); + return true; + } finally { + clearTimeout(id); + } + } catch (e) { + return false; + } +} // src/services/authService.ts export interface User { id: string; @@ -91,7 +115,8 @@ export interface User { export async function signIn(email: string, password: string): Promise { const normalizedEmail = String(email).trim().toLowerCase(); const useSupabase = Boolean(import.meta.env.VITE_LOCAL_USE_SUPABASE || import.meta.env.VITE_SUPABASE_URL); - if (useSupabase) { + const reachable = useSupabase ? await isSupabaseReachable() : false; + if (useSupabase && reachable) { const result = await supabase.auth.signInWithPassword({ email: normalizedEmail, password }); // Supabase may return error with status 400 and message indicating "User is not confirmed" or similar. if (result.error || !result.data?.session) { @@ -166,7 +191,8 @@ export async function signIn(email: string, password: string): Promise { export async function signUp(email: string, username: string, password: string): Promise { const normalizedEmail = String(email).trim().toLowerCase(); const useSupabase = Boolean(import.meta.env.VITE_LOCAL_USE_SUPABASE || import.meta.env.VITE_SUPABASE_URL); - if (useSupabase) { + const reachable = useSupabase ? await isSupabaseReachable() : false; + if (useSupabase && reachable) { const result = await supabase.auth.signUp({ email: normalizedEmail, password, options: { data: { username, energy: DEFAULT_ENERGY } } }); if (result.error) throw new Error(result.error.message || 'Sign up failed'); // Supabase may not return a session depending on config; if a session exists save token @@ -227,7 +253,8 @@ export async function signUp(email: string, username: string, password: string): // unsupported. export async function signInWithGoogle(): Promise { const useSupabase = Boolean(import.meta.env.VITE_LOCAL_USE_SUPABASE || import.meta.env.VITE_SUPABASE_URL); - if (useSupabase) { + const reachable = useSupabase ? await isSupabaseReachable() : false; + if (useSupabase && reachable) { try { // Prefer an explicit public app URL (so Supabase redirects back to your // branded domain after it finishes the provider exchange). If you set