mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 08:11:12 -05:00
feat: Implement OAuth profile enhancement
This commit is contained in:
@@ -44,6 +44,7 @@ import BlogIndex from "./pages/BlogIndex";
|
|||||||
import BlogPost from "./pages/BlogPost";
|
import BlogPost from "./pages/BlogPost";
|
||||||
import AdminBlog from "./pages/AdminBlog";
|
import AdminBlog from "./pages/AdminBlog";
|
||||||
import ForceLogout from "./pages/ForceLogout";
|
import ForceLogout from "./pages/ForceLogout";
|
||||||
|
import AuthCallback from "./pages/AuthCallback";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ function AppContent() {
|
|||||||
<Route path="/operators/:slug" element={<OperatorDetail />} />
|
<Route path="/operators/:slug" element={<OperatorDetail />} />
|
||||||
<Route path="/operators/:operatorSlug/parks" element={<OperatorParks />} />
|
<Route path="/operators/:operatorSlug/parks" element={<OperatorParks />} />
|
||||||
<Route path="/auth" element={<Auth />} />
|
<Route path="/auth" element={<Auth />} />
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/profile/:username" element={<Profile />} />
|
<Route path="/profile/:username" element={<Profile />} />
|
||||||
<Route path="/settings" element={<UserSettings />} />
|
<Route path="/settings" element={<UserSettings />} />
|
||||||
|
|||||||
@@ -275,7 +275,11 @@ export default function Auth() {
|
|||||||
} = await supabase.auth.signInWithOAuth({
|
} = await supabase.auth.signInWithOAuth({
|
||||||
provider,
|
provider,
|
||||||
options: {
|
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;
|
if (error) throw error;
|
||||||
|
|||||||
154
src/pages/AuthCallback.tsx
Normal file
154
src/pages/AuthCallback.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-16">
|
||||||
|
<div className="max-w-md mx-auto text-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{status === 'processing' && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<h2 className="text-2xl font-bold">Setting up your profile...</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
We're preparing your ThrillWiki experience
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-green-500 flex items-center justify-center">
|
||||||
|
<span className="text-white text-xl">✓</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold">Welcome!</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Redirecting you to ThrillWiki...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-destructive flex items-center justify-center">
|
||||||
|
<span className="text-destructive-foreground text-xl">✕</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Redirecting you to sign in...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
supabase/functions/process-oauth-profile/index.ts
Normal file
230
supabase/functions/process-oauth-profile/index.ts
Normal file
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user