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' },
+ });
+ }
+});