diff --git a/src/App.tsx b/src/App.tsx index f04c6f3f..e9277e18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ import BlogIndex from "./pages/BlogIndex"; import BlogPost from "./pages/BlogPost"; import AdminBlog from "./pages/AdminBlog"; import ForceLogout from "./pages/ForceLogout"; +import AuthCallback from "./pages/AuthCallback"; const queryClient = new QueryClient(); @@ -84,6 +85,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index 80ac8ce2..25e6364e 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -275,7 +275,11 @@ export default function Auth() { } = await supabase.auth.signInWithOAuth({ provider, options: { - redirectTo: `${window.location.origin}/auth/callback` + redirectTo: `${window.location.origin}/auth/callback`, + // Request additional scopes for avatar access + scopes: provider === 'google' + ? 'email profile' + : 'identify email' } }); if (error) throw error; diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx new file mode 100644 index 00000000..ffbeb065 --- /dev/null +++ b/src/pages/AuthCallback.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2 } from 'lucide-react'; +import { Header } from '@/components/layout/Header'; + +export default function AuthCallback() { + const navigate = useNavigate(); + const { toast } = useToast(); + const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing'); + + useEffect(() => { + const processOAuthCallback = async () => { + try { + // Get the current session + const { data: { session }, error: sessionError } = await supabase.auth.getSession(); + + if (sessionError) { + console.error('[AuthCallback] Session error:', sessionError); + throw sessionError; + } + + if (!session) { + console.log('[AuthCallback] No session found, redirecting to auth'); + navigate('/auth'); + return; + } + + const user = session.user; + console.log('[AuthCallback] User authenticated:', user.id); + + // Check if this is a new OAuth user (created within last minute) + const createdAt = new Date(user.created_at); + const now = new Date(); + const isNewUser = (now.getTime() - createdAt.getTime()) < 60000; // 1 minute + + // Check if user has an OAuth provider + const provider = user.app_metadata?.provider; + const isOAuthUser = provider === 'google' || provider === 'discord'; + + console.log('[AuthCallback] User info:', { + isNewUser, + isOAuthUser, + provider, + createdAt: user.created_at, + }); + + // If new OAuth user, process profile + if (isNewUser && isOAuthUser) { + setStatus('processing'); + + try { + console.log('[AuthCallback] Processing OAuth profile...'); + + const { data, error } = await supabase.functions.invoke('process-oauth-profile', { + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + }); + + if (error) { + console.error('[AuthCallback] Profile processing error:', error); + // Don't throw - allow sign-in to continue even if profile processing fails + } else { + console.log('[AuthCallback] Profile processed:', data); + } + } catch (error) { + console.error('[AuthCallback] Failed to process profile:', error); + // Continue anyway - don't block sign-in + } + } + + setStatus('success'); + + // Show success message + toast({ + title: 'Welcome to ThrillWiki!', + description: isNewUser + ? 'Your account has been created successfully.' + : 'You have been signed in successfully.', + }); + + // Redirect to home after a short delay + setTimeout(() => { + navigate('/'); + }, 500); + + } catch (error: any) { + console.error('[AuthCallback] Error:', error); + setStatus('error'); + + toast({ + variant: 'destructive', + title: 'Sign in error', + description: error.message || 'An error occurred during sign in. Please try again.', + }); + + // Redirect to auth page after error + setTimeout(() => { + navigate('/auth'); + }, 2000); + } + }; + + processOAuthCallback(); + }, [navigate, toast]); + + return ( +
+
+ +
+
+
+ {status === 'processing' && ( + <> + +

Setting up your profile...

+

+ We're preparing your ThrillWiki experience +

+ + )} + + {status === 'success' && ( + <> +
+ +
+

Welcome!

+

+ Redirecting you to ThrillWiki... +

+ + )} + + {status === 'error' && ( + <> +
+ +
+

Something went wrong

+

+ Redirecting you to sign in... +

+ + )} +
+
+
+
+ ); +} diff --git a/supabase/functions/process-oauth-profile/index.ts b/supabase/functions/process-oauth-profile/index.ts new file mode 100644 index 00000000..cd242c87 --- /dev/null +++ b/supabase/functions/process-oauth-profile/index.ts @@ -0,0 +1,230 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID'); +const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN'); + +interface GoogleUserMetadata { + email?: string; + name?: string; + picture?: string; + email_verified?: boolean; +} + +interface DiscordUserMetadata { + email?: string; + username?: string; + global_name?: string; + avatar?: string; + id?: string; +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Missing authorization header' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseKey); + + // Verify JWT and get user + const token = authHeader.replace('Bearer ', ''); + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + console.error('[OAuth Profile] Authentication failed:', authError); + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + console.log('[OAuth Profile] Processing profile for user:', user.id); + + const provider = user.app_metadata?.provider; + const userMetadata = user.user_metadata; + + let avatarUrl: string | null = null; + let displayName: string | null = null; + let usernameBase: string | null = null; + + // Extract provider-specific data + if (provider === 'google') { + const googleData = userMetadata as GoogleUserMetadata; + avatarUrl = googleData.picture || null; + displayName = googleData.name || null; + usernameBase = googleData.email?.split('@')[0] || null; + console.log('[OAuth Profile] Google user:', { avatarUrl, displayName, usernameBase }); + } else if (provider === 'discord') { + const discordData = userMetadata as DiscordUserMetadata; + displayName = discordData.global_name || discordData.username || null; + usernameBase = discordData.username || null; + + // Construct Discord avatar URL + if (discordData.avatar && discordData.id) { + avatarUrl = `https://cdn.discordapp.com/avatars/${discordData.id}/${discordData.avatar}.png?size=512`; + } + console.log('[OAuth Profile] Discord user:', { avatarUrl, displayName, usernameBase }); + } else { + console.log('[OAuth Profile] Unsupported provider:', provider); + return new Response(JSON.stringify({ success: true, message: 'Provider not supported' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Check if profile already has avatar + const { data: profile } = await supabase + .from('profiles') + .select('avatar_image_id, username') + .eq('user_id', user.id) + .single(); + + if (profile?.avatar_image_id) { + console.log('[OAuth Profile] Avatar already exists, skipping'); + return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + let cloudflareImageId: string | null = null; + let cloudflareImageUrl: string | null = null; + + // Download and upload avatar to Cloudflare + if (avatarUrl) { + try { + console.log('[OAuth Profile] Downloading avatar from:', avatarUrl); + + // Download image with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const imageResponse = await fetch(avatarUrl, { + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!imageResponse.ok) { + throw new Error(`Failed to download avatar: ${imageResponse.statusText}`); + } + + const imageBlob = await imageResponse.blob(); + + // Validate image size (max 10MB) + if (imageBlob.size > 10 * 1024 * 1024) { + throw new Error('Image too large (max 10MB)'); + } + + console.log('[OAuth Profile] Downloaded image:', { + size: imageBlob.size, + type: imageBlob.type, + }); + + // Get upload URL from Cloudflare + const uploadUrlResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, + }, + } + ); + + if (!uploadUrlResponse.ok) { + throw new Error('Failed to get Cloudflare upload URL'); + } + + const uploadData = await uploadUrlResponse.json(); + const uploadURL = uploadData.result.uploadURL; + + console.log('[OAuth Profile] Got Cloudflare upload URL'); + + // Upload to Cloudflare + const formData = new FormData(); + formData.append('file', imageBlob, 'avatar.png'); + + const uploadResponse = await fetch(uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to upload to Cloudflare'); + } + + const result = await uploadResponse.json(); + + if (result.success) { + cloudflareImageId = result.result.id; + cloudflareImageUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_ID}/${cloudflareImageId}/avatar`; + console.log('[OAuth Profile] Uploaded to Cloudflare:', { cloudflareImageId, cloudflareImageUrl }); + } else { + throw new Error('Cloudflare upload failed'); + } + } catch (error) { + console.error('[OAuth Profile] Avatar upload failed:', error); + // Continue without avatar - don't block profile creation + } + } + + // Update profile with enhanced data + const updateData: any = {}; + + if (cloudflareImageId) { + updateData.avatar_image_id = cloudflareImageId; + updateData.avatar_url = cloudflareImageUrl; + } + + if (displayName) { + updateData.display_name = displayName; + } + + // Only update if we have data to update + if (Object.keys(updateData).length > 0) { + const { error: updateError } = await supabase + .from('profiles') + .update(updateData) + .eq('user_id', user.id); + + if (updateError) { + console.error('[OAuth Profile] Failed to update profile:', updateError); + return new Response(JSON.stringify({ error: 'Failed to update profile' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + console.log('[OAuth Profile] Profile updated successfully'); + } + + return new Response(JSON.stringify({ + success: true, + avatar_uploaded: !!cloudflareImageId, + profile_updated: Object.keys(updateData).length > 0, + }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('[OAuth Profile] Error:', error); + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +});