From 4e9aa97fdf62c77ce8fb9fde41e1e6679d0a145f Mon Sep 17 00:00:00 2001 From: Deepak Pandey Date: Sun, 14 Sep 2025 21:51:15 +0530 Subject: [PATCH 1/2] feat: Add OAuth profile completion flow - Modify OAuth callback to check profile completeness - Redirect incomplete OAuth profiles to /complete-profile page - Create new /complete-profile page with form for first_name, last_name, username - Add real-time username validation with debounced checking - Pre-fill form data from OAuth provider metadata (Google/GitHub) - Update middleware to allow access to /complete-profile route - Preserve existing email signup flow unchanged - Add username generation feature for convenience Fixes: OAuth users now complete their profiles instead of using random usernames Impact: Improved user experience for OAuth authentication flow --- app/auth/callback/page.tsx | 89 +++++++-- app/complete-profile/page.tsx | 345 ++++++++++++++++++++++++++++++++++ middleware.ts | 1 + 3 files changed, 419 insertions(+), 16 deletions(-) create mode 100644 app/complete-profile/page.tsx diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index c0cc93f5..a9270f02 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -29,11 +29,11 @@ function OAuthCallbackContent() { } if (session?.user) { - // User is authenticated, ensure profile exists + // User is authenticated, check profile completeness try { const { data: profile } = await supabase .from('profiles') - .select('id') + .select('id, first_name, last_name, username, profile_complete') .eq('id', session.user.id) .single(); @@ -46,14 +46,33 @@ function OAuthCallbackContent() { auth_provider: provider, user_metadata: session.user.user_metadata || {} }); + + // After creating profile, redirect to complete profile + console.log('Profile created, redirecting to complete profile'); + router.replace('/complete-profile'); + return; + } + + // Check if profile is complete (has first_name, last_name, and username) + const isProfileComplete = profile.first_name && + profile.last_name && + profile.username && + profile.profile_complete; + + if (!isProfileComplete) { + console.log('Profile incomplete, redirecting to complete profile'); + router.replace('/complete-profile'); + return; } } catch (profileError) { - console.error('Error creating profile for OAuth user:', profileError); - // Continue anyway, the user can still proceed + console.error('Error checking profile for OAuth user:', profileError); + // If there's an error, redirect to complete profile to be safe + router.replace('/complete-profile'); + return; } - // User is authenticated, redirect to return URL - console.log('User authenticated, redirecting to:', returnUrl); + // User is authenticated and profile is complete, redirect to return URL + console.log('User authenticated with complete profile, redirecting to:', returnUrl); toast.success("Signed in successfully!"); router.replace(returnUrl); return; @@ -87,11 +106,11 @@ function OAuthCallbackContent() { } if (retrySession?.user) { - // User is authenticated, ensure profile exists + // User is authenticated, check profile completeness try { const { data: profile } = await supabase .from('profiles') - .select('id') + .select('id, first_name, last_name, username, profile_complete') .eq('id', retrySession.user.id) .single(); @@ -104,13 +123,32 @@ function OAuthCallbackContent() { auth_provider: provider, user_metadata: retrySession.user.user_metadata || {} }); + + // After creating profile, redirect to complete profile + console.log('Profile created on retry, redirecting to complete profile'); + router.replace('/complete-profile'); + return; + } + + // Check if profile is complete (has first_name, last_name, and username) + const isProfileComplete = profile.first_name && + profile.last_name && + profile.username && + profile.profile_complete; + + if (!isProfileComplete) { + console.log('Profile incomplete on retry, redirecting to complete profile'); + router.replace('/complete-profile'); + return; } } catch (profileError) { - console.error('Error creating profile for OAuth user:', profileError); - // Continue anyway, the user can still proceed + console.error('Error checking profile for OAuth user on retry:', profileError); + // If there's an error, redirect to complete profile to be safe + router.replace('/complete-profile'); + return; } - console.log('User authenticated on retry, redirecting to:', returnUrl); + console.log('User authenticated with complete profile on retry, redirecting to:', returnUrl); toast.success("Signed in successfully!"); router.replace(returnUrl); } else { @@ -120,11 +158,11 @@ function OAuthCallbackContent() { const { data: { session: finalSession } } = await supabase.auth.getSession(); console.log('Final session check:', { session: !!finalSession }); if (finalSession?.user) { - // User is authenticated, ensure profile exists + // User is authenticated, check profile completeness try { const { data: profile } = await supabase .from('profiles') - .select('id') + .select('id, first_name, last_name, username, profile_complete') .eq('id', finalSession.user.id) .single(); @@ -137,13 +175,32 @@ function OAuthCallbackContent() { auth_provider: provider, user_metadata: finalSession.user.user_metadata || {} }); + + // After creating profile, redirect to complete profile + console.log('Profile created on final try, redirecting to complete profile'); + router.replace('/complete-profile'); + return; + } + + // Check if profile is complete (has first_name, last_name, and username) + const isProfileComplete = profile.first_name && + profile.last_name && + profile.username && + profile.profile_complete; + + if (!isProfileComplete) { + console.log('Profile incomplete on final try, redirecting to complete profile'); + router.replace('/complete-profile'); + return; } } catch (profileError) { - console.error('Error creating profile for OAuth user:', profileError); - // Continue anyway, the user can still proceed + console.error('Error checking profile for OAuth user on final try:', profileError); + // If there's an error, redirect to complete profile to be safe + router.replace('/complete-profile'); + return; } - console.log('User authenticated on final try, redirecting to:', returnUrl); + console.log('User authenticated with complete profile on final try, redirecting to:', returnUrl); toast.success("Signed in successfully!"); router.replace(returnUrl); } else { diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx new file mode 100644 index 00000000..b50769ea --- /dev/null +++ b/app/complete-profile/page.tsx @@ -0,0 +1,345 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { createClient } from '@/lib/supabase/client'; +import { toast } from 'sonner'; + +interface User { + id: string; + email?: string; + user_metadata?: { + first_name?: string; + last_name?: string; + given_name?: string; + family_name?: string; + name?: string; + }; +} + +export default function CompleteProfile() { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [username, setUsername] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingUsername, setIsCheckingUsername] = useState(false); + const [usernameAvailable, setUsernameAvailable] = useState(null); + const [user, setUser] = useState(null); + const [isValidating, setIsValidating] = useState(true); + const router = useRouter(); + + const getSupabaseClient = () => { + return createClient(); + }; + + const checkUser = useCallback(async () => { + try { + const { data: { user } } = await getSupabaseClient().auth.getUser(); + if (!user) { + router.push('/auth/signin'); + return; + } + setUser(user); + + // Check if profile already exists and is complete + const { data: profile } = await getSupabaseClient() + .from('profiles') + .select('first_name, last_name, username, profile_complete') + .eq('id', user.id) + .single(); + + if (profile) { + const isProfileComplete = profile.first_name && + profile.last_name && + profile.username && + profile.profile_complete; + + if (isProfileComplete) { + // Profile is already complete, redirect to dashboard + router.push('/protected/dashboard'); + return; + } + + // Pre-fill existing data + if (profile.first_name) setFirstName(profile.first_name); + if (profile.last_name) setLastName(profile.last_name); + if (profile.username) setUsername(profile.username); + } + + // Pre-fill from OAuth provider data if available + if (user.user_metadata) { + const metadata = user.user_metadata; + if (!firstName && (metadata.first_name || metadata.given_name)) { + setFirstName(metadata.first_name || metadata.given_name || ''); + } + if (!lastName && (metadata.last_name || metadata.family_name)) { + setLastName(metadata.last_name || metadata.family_name || ''); + } + } + + } catch (error) { + console.error('Error checking user:', error); + toast.error('Error loading profile data'); + } finally { + setIsValidating(false); + } + }, [router, firstName, lastName]); + + useEffect(() => { + checkUser(); + }, [checkUser]); + + const checkUsernameAvailability = async (usernameToCheck: string) => { + if (!usernameToCheck || usernameToCheck.length < 3) { + setUsernameAvailable(null); + return; + } + + setIsCheckingUsername(true); + try { + const { data: isAvailable } = await getSupabaseClient().rpc('check_username_availability', { + username_param: usernameToCheck + }); + setUsernameAvailable(isAvailable); + } catch (error) { + console.error('Error checking username:', error); + setUsernameAvailable(false); + } finally { + setIsCheckingUsername(false); + } + }; + + const handleUsernameChange = (value: string) => { + setUsername(value); + // Debounce username check + const timeoutId = setTimeout(() => { + checkUsernameAvailability(value); + }, 500); + + return () => clearTimeout(timeoutId); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!user) { + toast.error('User not found. Please sign in again.'); + return; + } + + // Validate required fields + if (!firstName.trim()) { + toast.error('First name is required'); + return; + } + + if (!lastName.trim()) { + toast.error('Last name is required'); + return; + } + + if (!username || username.length < 3) { + toast.error('Username must be at least 3 characters long'); + return; + } + + if (!usernameAvailable) { + toast.error('Username is not available'); + return; + } + + setIsLoading(true); + try { + // Update profile with the provided information + const { error } = await getSupabaseClient() + .from('profiles') + .update({ + first_name: firstName.trim(), + last_name: lastName.trim(), + username: username.trim(), + profile_complete: true, + username_set: true, + username_editable: false + }) + .eq('id', user.id); + + if (error) { + console.error('Profile update error:', error); + toast.error(error.message || 'Failed to update profile. Please try again.'); + return; + } + + toast.success('Profile completed successfully! Welcome to CodeUnia! 🎉'); + router.push('/protected/dashboard'); + } catch (error) { + console.error('Error updating profile:', error); + toast.error('Error completing profile setup'); + } finally { + setIsLoading(false); + } + }; + + const generateRandomUsername = async () => { + try { + const { data: randomUsername } = await getSupabaseClient().rpc('generate_safe_username'); + if (randomUsername) { + setUsername(randomUsername); + checkUsernameAvailability(randomUsername); + } + } catch (error) { + console.error('Error generating username:', error); + toast.error('Error generating username'); + } + }; + + if (isValidating) { + return ( +
+
+
+
+
+ Loading... +
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ CU +
+

+ Welcome! Let's set up your CodeUnia profile. +

+

+ Complete your profile to get started with CodeUnia. This will only take a moment. +

+
+ + {/* Setup Form */} +
+ {/* First Name */} +
+ + setFirstName(e.target.value)} + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter your first name" + required + /> +
+ + {/* Last Name */} +
+ + setLastName(e.target.value)} + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter your last name" + required + /> +
+ + {/* Username Input */} +
+ +
+ handleUsernameChange(e.target.value)} + className={`w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${ + usernameAvailable === true + ? 'border-green-300 bg-green-50' + : usernameAvailable === false + ? 'border-red-300 bg-red-50' + : 'border-gray-300' + }`} + placeholder="Enter your username" + minLength={3} + maxLength={20} + pattern="[a-zA-Z0-9_-]+" + title="Username can only contain letters, numbers, hyphens, and underscores" + required + /> + +
+ + {/* Username Status */} + {isCheckingUsername && ( +

Checking availability...

+ )} + {usernameAvailable === true && ( +

✅ Username is available!

+ )} + {usernameAvailable === false && ( +

❌ Username is already taken

+ )} + + {/* Username Requirements */} +
+

• 3-20 characters long

+

• Letters, numbers, hyphens, and underscores only

+

• Must be unique across all users

+
+
+ + {/* Submit Button */} + +
+ + {/* Footer */} +
+

+ By continuing, you agree to CodeUnia's{' '} + Terms of Service + {' '}and{' '} + Privacy Policy +

+
+
+
+ ); +} diff --git a/middleware.ts b/middleware.ts index 1535bc8f..f23c462d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -109,6 +109,7 @@ export async function middleware(req: NextRequest) { '/auth/confirm', '/auth/sign-up-success', '/auth/email-confirmation-required', + '/complete-profile', '/verify/cert', '/terms', '/privacy', From a9fed5cf7ecdf801c38bbd04ec694a624b3ffb01 Mon Sep 17 00:00:00 2001 From: Deepak Pandey Date: Sun, 14 Sep 2025 22:05:12 +0530 Subject: [PATCH 2/2] fix: Address CodeRabbit feedback for OAuth profile completion - Fix debounce implementation using useRef for proper timeout management - Normalize username input by trimming before availability checks - Set usernameAvailable to null on network errors instead of false - Use upsert instead of update to handle missing profile rows - Add proper error handling and data validation for profile updates - Add cleanup effect to clear timeout on component unmount Improves robustness and reliability of the profile completion flow. --- app/complete-profile/page.tsx | 43 ++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index b50769ea..c99d48eb 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createClient } from '@/lib/supabase/client'; @@ -28,6 +28,7 @@ export default function CompleteProfile() { const [user, setUser] = useState(null); const [isValidating, setIsValidating] = useState(true); const router = useRouter(); + const usernameCheckTimeout = useRef | null>(null); const getSupabaseClient = () => { return createClient(); @@ -90,6 +91,15 @@ export default function CompleteProfile() { checkUser(); }, [checkUser]); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (usernameCheckTimeout.current) { + clearTimeout(usernameCheckTimeout.current); + } + }; + }, []); + const checkUsernameAvailability = async (usernameToCheck: string) => { if (!usernameToCheck || usernameToCheck.length < 3) { setUsernameAvailable(null); @@ -98,13 +108,14 @@ export default function CompleteProfile() { setIsCheckingUsername(true); try { + const clean = usernameToCheck.trim(); const { data: isAvailable } = await getSupabaseClient().rpc('check_username_availability', { - username_param: usernameToCheck + username_param: clean }); setUsernameAvailable(isAvailable); } catch (error) { console.error('Error checking username:', error); - setUsernameAvailable(false); + setUsernameAvailable(null); } finally { setIsCheckingUsername(false); } @@ -112,12 +123,10 @@ export default function CompleteProfile() { const handleUsernameChange = (value: string) => { setUsername(value); - // Debounce username check - const timeoutId = setTimeout(() => { - checkUsernameAvailability(value); + if (usernameCheckTimeout.current) clearTimeout(usernameCheckTimeout.current); + usernameCheckTimeout.current = setTimeout(() => { + checkUsernameAvailability(value.trim()); }, 500); - - return () => clearTimeout(timeoutId); }; const handleSubmit = async (e: React.FormEvent) => { @@ -151,18 +160,20 @@ export default function CompleteProfile() { setIsLoading(true); try { - // Update profile with the provided information - const { error } = await getSupabaseClient() + // Update profile with the provided information using upsert to handle missing profiles + const { data: upserted, error } = await getSupabaseClient() .from('profiles') - .update({ + .upsert([{ + id: user.id, first_name: firstName.trim(), last_name: lastName.trim(), username: username.trim(), profile_complete: true, username_set: true, username_editable: false - }) - .eq('id', user.id); + }], { onConflict: 'id' }) + .select('id') + .single(); if (error) { console.error('Profile update error:', error); @@ -170,6 +181,12 @@ export default function CompleteProfile() { return; } + if (!upserted) { + console.error('Profile update failed: No data returned'); + toast.error('Failed to update profile. Please try again.'); + return; + } + toast.success('Profile completed successfully! Welcome to CodeUnia! 🎉'); router.push('/protected/dashboard'); } catch (error) {