From c11e7a3237d2c27af33eec199a11cf35465ee485 Mon Sep 17 00:00:00 2001 From: "factory-droid[bot]" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 18:08:30 +0000 Subject: [PATCH 1/3] feat: Add Supabase authentication with Magic Link and GitHub - Install @supabase/supabase-js dependency - Create Supabase client configuration in src/lib/supabase.ts - Add Magic Link and GitHub authentication tabs to LoginPage - Update guest mode to use Supabase anonymous sign-in - Extend AuthContext to support both keystore and Supabase authentication - Update Index page to handle both authentication methods - Maintain all existing UI/UX design and functionality - Keep keystore authentication fully functional alongside Supabase options --- package.json | 1 + src/components/LoginPage.tsx | 179 +++++++++++++- src/contexts/AuthContext.tsx | 451 ++++++++++++++++++++++++++++++----- src/lib/supabase.ts | 51 ++++ src/pages/Index.tsx | 36 ++- 5 files changed, 637 insertions(+), 81 deletions(-) create mode 100644 src/lib/supabase.ts diff --git a/package.json b/package.json index 95aad28f..23e80235 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@supabase/supabase-js": "^2.39.7", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 65d80428..70c5119d 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,10 +1,10 @@ /** * @file LoginPage.tsx - * @version 1.1.0 + * @version 1.2.0 * @description Main login and account creation page for Python Quest. * Offers users options to log in with a keystore, create a new self-custody account, - * or try the platform as a guest. Emphasizes user data sovereignty and security. - * Includes password reveal functionality for improved UX. + * or try the platform as a guest. Now integrates Supabase for additional + * authentication methods like Magic Link and GitHub, and anonymous guest access. * * @project Python Quest - A Gamified Python Learning Platform * @author Factory AI Development Team @@ -17,11 +17,19 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { KeyRound, UserPlus, ShieldCheck, UploadCloud, LogIn, PlayCircle, Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react'; +import { KeyRound, UserPlus, ShieldCheck, UploadCloud, LogIn, PlayCircle, Loader2, AlertTriangle, Eye, EyeOff, Mail, Github } from 'lucide-react'; import { ThemeProvider } from '@/contexts/ThemeContext'; +import supabase from '@/lib/supabase'; const LoginPage: React.FC = () => { - const { createAccount, loginWithKeystore, switchToGuestMode, isLoading, error: authError, setError: setAuthError } = useAuth(); + const { + createAccount, + loginWithKeystore, + switchToGuestMode, + isLoading, + error: authError, + setError: setAuthError + } = useAuth(); // State for active tab const [activeTab, setActiveTab] = useState("login"); @@ -39,9 +47,15 @@ const LoginPage: React.FC = () => { const [loginError, setLoginError] = useState(null); const [showLoginPassword, setShowLoginPassword] = useState(false); + // State for Magic Link form + const [magicLinkEmail, setMagicLinkEmail] = useState(''); + const [magicLinkError, setMagicLinkError] = useState(null); + const [magicLinkSent, setMagicLinkSent] = useState(false); + const clearLocalErrors = () => { setCreateError(null); setLoginError(null); + setMagicLinkError(null); if (setAuthError) setAuthError(null); // Clear global auth error if method is available }; @@ -55,9 +69,10 @@ const LoginPage: React.FC = () => { const fileInput = document.getElementById('keystoreFile') as HTMLInputElement; if (fileInput) fileInput.value = ''; // Reset file input setLoginPassword(''); + setMagicLinkEmail(''); + setMagicLinkSent(false); }; - const handleCreateAccount = async (e: FormEvent) => { e.preventDefault(); clearLocalErrors(); @@ -98,10 +113,62 @@ const LoginPage: React.FC = () => { } }; - const handleGuestMode = () => { + // Modified to use Supabase anonymous sign-in + const handleGuestMode = async () => { clearLocalErrors(); - switchToGuestMode(); - // AuthContext handles navigation/state change + try { + const { data, error } = await supabase.auth.signInAnonymously(); + if (error) throw error; + // After successful anonymous sign-in, switch to guest mode in AuthContext + switchToGuestMode(); + } catch (error: any) { + if (setAuthError) setAuthError(error.message || "Failed to sign in as guest"); + } + }; + + // New handler for Magic Link authentication + const handleMagicLinkSignIn = async (e: FormEvent) => { + e.preventDefault(); + clearLocalErrors(); + + if (!magicLinkEmail || !/^\S+@\S+\.\S+$/.test(magicLinkEmail)) { + setMagicLinkError("Please enter a valid email address."); + return; + } + + try { + const { data, error } = await supabase.auth.signInWithOtp({ + email: magicLinkEmail, + options: { + emailRedirectTo: window.location.origin, + } + }); + + if (error) throw error; + + // Show success message + setMagicLinkSent(true); + } catch (error: any) { + setMagicLinkError(error.message || "Failed to send magic link"); + } + }; + + // New handler for GitHub authentication + const handleGitHubSignIn = async () => { + clearLocalErrors(); + try { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: window.location.origin + } + }); + + if (error) throw error; + // GitHub OAuth will handle the redirect flow + } catch (error: any) { + if (setAuthError) setAuthError(error.message || "Failed to sign in with GitHub"); + } }; return ( @@ -119,7 +186,7 @@ const LoginPage: React.FC = () => {
- + Login @@ -129,6 +196,12 @@ const LoginPage: React.FC = () => { Guest + + Magic + + + GitHub + {/* Login with Keystore Tab */} @@ -268,7 +341,7 @@ const LoginPage: React.FC = () => { - {/* Try as Guest Tab */} + {/* Try as Guest Tab (Updated to use Supabase anonymous sign-in) */} @@ -279,7 +352,7 @@ const LoginPage: React.FC = () => {

- Click below to start learning immediately. If you enjoy Python Quest, you can create a secure, self-custody keystore account at any time from the main dashboard to save your progress permanently across devices. + Click below to start learning immediately. If you enjoy Python Quest, you can create a secure account at any time from the main dashboard to save your progress permanently across devices.

@@ -290,6 +363,88 @@ const LoginPage: React.FC = () => {
+ + {/* Magic Link Tab (New) */} + + + + Magic Link Sign In + + Receive a sign-in link via email - no password needed. + + +
+ + {!magicLinkSent ? ( +
+ +
+ + {setMagicLinkEmail(e.target.value); setMagicLinkError(null);}} + placeholder="Enter your email address" + className="bg-gray-700 border-gray-600 text-white placeholder-gray-500 pl-10" + disabled={isLoading} + /> +
+ {magicLinkError &&

{magicLinkError}

} + {authError && activeTab === 'magic' &&

{authError}

} +
+ ) : ( +
+ +

Check Your Email

+

We've sent a magic link to:

+

{magicLinkEmail}

+

Click the link in the email to sign in to Python Quest.

+
+ )} +
+ {!magicLinkSent && ( + + + + )} +
+
+
+ + {/* GitHub Tab (New) */} + + + + GitHub Sign In + + Use your GitHub account to sign in quickly and securely. + + + +
+ +

+ Connect with your GitHub account to access Python Quest. Your GitHub email will be used for your account. +

+ {authError && activeTab === 'github' &&

{authError}

} +
+
+ + + +
+
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 554306f1..aa393a24 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,17 +1,21 @@ /** * @file AuthContext.tsx - * @version 1.1.0-ecdsa - * @description Manages user authentication state and keystore-based self-custody logic. - * This context handles guest mode, keystore generation, login, logout, and ensures - * client-side cryptographic operations for user security and data sovereignty. - * Uses ECDSA P-256 for key pairs and AES-GCM for keystore encryption. + * @version 2.0.0 + * @description Manages user authentication state with dual authentication support: + * 1. Keystore-based self-custody authentication (original) + * 2. Supabase-based authentication (new) + * + * This context handles guest mode, keystore operations, Supabase auth methods, + * and ensures client-side cryptographic operations for user security. * * @project Python Quest - A Gamified Python Learning Platform * @author Factory AI Development Team - * @date June 2, 2025 + * @date June 10, 2025 */ import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import supabase from '@/lib/supabase'; // Import Supabase client +import { Session, User, AuthError } from '@supabase/supabase-js'; // Import Supabase types // ===================================================================================== // CONSTANTS & CONFIGURATION @@ -33,7 +37,16 @@ const SESSION_STORAGE_KEY = 'pythonQuestAuth'; // TYPE DEFINITIONS & INTERFACES // ===================================================================================== -export type AuthMode = 'unauthenticated' | 'guest' | 'keystore'; +// Extended AuthMode to include Supabase authentication +export type AuthMode = 'unauthenticated' | 'guest' | 'keystore' | 'supabase'; + +// Profile type for Supabase users +export type Profile = { + id: string; + username: string | null; + updated_at: string; + created_at: string; +}; export interface Keystore { publicKeyHex: string; // User's public key (ECDSA P-256, SPKI format), hex encoded @@ -50,19 +63,39 @@ export interface AuthUser { publicKeyHex: string; } +// Extended AuthContextType to include Supabase functionality interface AuthContextType { + // General auth state authMode: AuthMode; - currentUser: AuthUser | null; isLoading: boolean; error: string | null; - setError: (message: string | null) => void; // Added setError - isAuthenticated: boolean; - isGuest: boolean; + setError: (message: string | null) => void; + + // Auth state flags + isAuthenticated: boolean; // True for both keystore and supabase auth + isGuest: boolean; // True for guest mode + isKeystoreAuthenticated: boolean; // True only for keystore auth + isSupabaseAuthenticated: boolean; // True only for supabase auth + + // Keystore-specific properties and methods + currentUser: AuthUser | null; createAccount: (password: string) => Promise; loginWithKeystore: (keystoreFile: File, password: string) => Promise; - logout: () => void; - switchToGuestMode: () => void; signData: (data: string) => Promise; + + // Supabase-specific properties and methods + supabaseUser: User | null; + supabaseSession: Session | null; + profile: Profile | null; + signInWithGitHub: () => Promise; + signInWithMagicLink: (email: string) => Promise; + signInAnonymously: () => Promise; + updateUsername: (username: string) => Promise; + refreshProfile: () => Promise; + + // Combined methods that work for both auth types + logout: () => Promise; + switchToGuestMode: () => void; } // Helper to convert ArrayBuffer to Base64 string @@ -97,57 +130,85 @@ const arrayBufferToHex = (buffer: ArrayBuffer): string => { const AuthContext = createContext(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [authMode, setAuthMode] = useState('unauthenticated'); + // Keystore-specific state + const [keystoreAuthMode, setKeystoreAuthMode] = useState<'unauthenticated' | 'guest' | 'keystore'>('unauthenticated'); const [currentUser, setCurrentUser] = useState(null); + const [sessionPrivateKey, setSessionPrivateKey] = useState(null); + + // Supabase-specific state + const [supabaseUser, setSupabaseUser] = useState(null); + const [supabaseSession, setSupabaseSession] = useState(null); + const [profile, setProfile] = useState(null); + + // General auth state const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [sessionPrivateKey, setSessionPrivateKey] = useState(null); + // Computed auth mode based on both auth systems + const authMode: AuthMode = useCallback(() => { + if (keystoreAuthMode === 'keystore') return 'keystore'; + if (supabaseUser) { + if (supabaseUser.app_metadata?.provider === 'anonymous') return 'guest'; + return 'supabase'; + } + if (keystoreAuthMode === 'guest') return 'guest'; + return 'unauthenticated'; + }, [keystoreAuthMode, supabaseUser])(); + + // Computed auth flags + const isAuthenticated = authMode === 'keystore' || authMode === 'supabase'; + const isGuest = authMode === 'guest'; + const isKeystoreAuthenticated = authMode === 'keystore'; + const isSupabaseAuthenticated = authMode === 'supabase'; + + // ===================================================================================== + // KEYSTORE AUTHENTICATION LOGIC (EXISTING) + // ===================================================================================== + + // Initialize keystore auth state from session storage useEffect(() => { - setIsLoading(true); - console.debug("[AuthContext] Initializing auth state from session storage..."); + console.debug("[AuthContext] Initializing keystore auth state from session storage..."); try { const storedAuth = sessionStorage.getItem(SESSION_STORAGE_KEY); if (storedAuth) { const { mode, user } = JSON.parse(storedAuth); if (mode === 'keystore' && user && user.publicKeyHex) { - setAuthMode('keystore'); + setKeystoreAuthMode('keystore'); setCurrentUser(user); console.debug("[AuthContext] Keystore session restored (publicKey only)."); } else if (mode === 'guest') { - setAuthMode('guest'); + setKeystoreAuthMode('guest'); console.debug("[AuthContext] Guest session restored."); } else { - setAuthMode('unauthenticated'); + setKeystoreAuthMode('unauthenticated'); } } else { - setAuthMode('unauthenticated'); + setKeystoreAuthMode('unauthenticated'); console.debug("[AuthContext] No session state found, defaulting to unauthenticated."); } } catch (e) { - console.error("[AuthContext] Error initializing auth state:", e); - setAuthMode('unauthenticated'); - } finally { - setIsLoading(false); + console.error("[AuthContext] Error initializing keystore auth state:", e); + setKeystoreAuthMode('unauthenticated'); } }, []); + // Persist keystore auth state to session storage useEffect(() => { if (!isLoading) { - console.debug("[AuthContext] Persisting auth state to session storage:", { authMode, currentUser }); + console.debug("[AuthContext] Persisting keystore auth state to session storage:", { keystoreAuthMode, currentUser }); try { - if (authMode === 'keystore' && currentUser) { - sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ mode: authMode, user: currentUser })); - } else if (authMode === 'guest') { - sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ mode: authMode, user: null })); + if (keystoreAuthMode === 'keystore' && currentUser) { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ mode: keystoreAuthMode, user: currentUser })); + } else if (keystoreAuthMode === 'guest') { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ mode: keystoreAuthMode, user: null })); } else { sessionStorage.removeItem(SESSION_STORAGE_KEY); } } catch(e) { - console.error("[AuthContext] Error persisting auth state:", e); + console.error("[AuthContext] Error persisting keystore auth state:", e); } } - }, [authMode, currentUser, isLoading]); + }, [keystoreAuthMode, currentUser, isLoading]); const generateEcdsaKeyPair = async (): Promise => { console.debug("[AuthContext] Generating ECDSA P-256 key pair..."); @@ -314,14 +375,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const user: AuthUser = { publicKeyHex }; setCurrentUser(user); - setAuthMode('keystore'); + setKeystoreAuthMode('keystore'); setSessionPrivateKey(keyPair.privateKey); console.info("[AuthContext] Account created successfully. User is now keystore authenticated."); } catch (e: any) { console.error("[AuthContext] Error creating account:", e, e.stack); setError(`Account creation failed: ${e.message || 'Unknown cryptographic error'}`); - setAuthMode('unauthenticated'); + setKeystoreAuthMode('unauthenticated'); setCurrentUser(null); setSessionPrivateKey(null); } finally { @@ -359,7 +420,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const user: AuthUser = { publicKeyHex: keystoreData.publicKeyHex }; setCurrentUser(user); - setAuthMode('keystore'); + setKeystoreAuthMode('keystore'); setSessionPrivateKey(privateKey); console.info("[AuthContext] Login successful. User is now keystore authenticated."); @@ -370,7 +431,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } else { setError(`Login failed: ${e.message || 'Unknown error during login'}`); } - setAuthMode('unauthenticated'); + setKeystoreAuthMode('unauthenticated'); setCurrentUser(null); setSessionPrivateKey(null); } finally { @@ -378,29 +439,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }, []); - const logout = useCallback((): void => { - console.debug("[AuthContext] Logging out..."); - setAuthMode('unauthenticated'); - setCurrentUser(null); - setSessionPrivateKey(null); - setError(null); - sessionStorage.removeItem(SESSION_STORAGE_KEY); - console.info("[AuthContext] User logged out."); - }, []); - - const switchToGuestMode = useCallback((): void => { - console.debug("[AuthContext] Switching to guest mode..."); - if (authMode === 'keystore') { - setCurrentUser(null); - setSessionPrivateKey(null); - } - setAuthMode('guest'); - setError(null); - console.info("[AuthContext] Switched to guest mode."); - }, [authMode]); - const signData = useCallback(async (dataString: string): Promise => { - if (!sessionPrivateKey || authMode !== 'keystore') { + if (!sessionPrivateKey || keystoreAuthMode !== 'keystore') { const msg = "Not authenticated with a keystore to sign data."; console.warn(`[AuthContext] ${msg}`); setError(msg); @@ -422,21 +462,304 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setError(`Signing failed: ${e.message || 'Unknown cryptographic error during signing'}`); return null; } - }, [sessionPrivateKey, authMode]); + }, [sessionPrivateKey, keystoreAuthMode]); + + // ===================================================================================== + // SUPABASE AUTHENTICATION LOGIC (NEW) + // ===================================================================================== + + // Fetch user profile from Supabase + const fetchProfile = useCallback(async (userId: string): Promise => { + try { + const { data, error } = await supabase + .from("profiles") + .select("id, username, updated_at, created_at") + .eq("id", userId) + .single(); + + if (error) { + console.error("[AuthContext] Error fetching profile:", error); + return null; + } + + return data as Profile; + } catch (error) { + console.error("[AuthContext] Error in fetchProfile:", error); + return null; + } + }, []); + + // Initialize Supabase auth state and set up listener + useEffect(() => { + const initializeSupabaseAuth = async () => { + setIsLoading(true); + try { + // Check for existing session + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) { + throw error; + } + + if (session) { + const user = session.user; + const userProfile = await fetchProfile(user.id); + + setSupabaseUser(user); + setSupabaseSession(session); + setProfile(userProfile); + } + } catch (error) { + console.error("[AuthContext] Error initializing Supabase auth:", error); + } finally { + setIsLoading(false); + } + }; + + initializeSupabaseAuth(); + + // Set up auth state change listener + const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => { + console.log("[AuthContext] Supabase auth state changed:", event); + + if (event === "SIGNED_IN" && session) { + const user = session.user; + const userProfile = await fetchProfile(user.id); + + setSupabaseUser(user); + setSupabaseSession(session); + setProfile(userProfile); + } else if (event === "SIGNED_OUT" || event === "USER_DELETED") { + setSupabaseUser(null); + setSupabaseSession(null); + setProfile(null); + } + }); + + // Clean up subscription on unmount + return () => { + authListener.subscription.unsubscribe(); + }; + }, [fetchProfile]); + + // Refresh profile data + const refreshProfile = useCallback(async (): Promise => { + if (!supabaseUser) return; + + try { + const userProfile = await fetchProfile(supabaseUser.id); + setProfile(userProfile); + } catch (error) { + console.error("[AuthContext] Error refreshing profile:", error); + } + }, [supabaseUser, fetchProfile]); + + // Sign in with GitHub + const signInWithGitHub = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "github", + options: { + redirectTo: `${window.location.origin}/`, + }, + }); + + if (error) { + throw error; + } + // Auth state will be updated by the listener + } catch (error: any) { + console.error("[AuthContext] Error signing in with GitHub:", error); + setError(error.message || "Failed to sign in with GitHub"); + } finally { + setIsLoading(false); + } + }, []); + + // Sign in with magic link + const signInWithMagicLink = useCallback(async (email: string): Promise => { + setIsLoading(true); + setError(null); + try { + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${window.location.origin}/`, + }, + }); + + if (error) { + throw error; + } + + console.info("[AuthContext] Magic link sent successfully."); + } catch (error: any) { + console.error("[AuthContext] Error sending magic link:", error); + setError(error.message || "Failed to send magic link"); + } finally { + setIsLoading(false); + } + }, []); + + // Sign in anonymously + const signInAnonymously = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + try { + const { error } = await supabase.auth.signInAnonymously(); + + if (error) { + throw error; + } + + console.info("[AuthContext] Anonymous sign-in successful."); + } catch (error: any) { + console.error("[AuthContext] Error signing in anonymously:", error); + setError(error.message || "Failed to sign in as guest"); + } finally { + setIsLoading(false); + } + }, []); + + // Update username + const updateUsername = useCallback(async (username: string): Promise => { + if (!supabaseUser) { + setError("You must be logged in to update your username"); + return; + } + + setIsLoading(true); + setError(null); + try { + // Check if username is already taken + const { data: existingUser, error: checkError } = await supabase + .from("profiles") + .select("username") + .eq("username", username) + .neq("id", supabaseUser.id) // Exclude current user + .maybeSingle(); + + if (checkError) { + throw checkError; + } + + if (existingUser) { + throw new Error("Username is already taken"); + } + + // Update the username + const { error } = await supabase + .from("profiles") + .update({ username, updated_at: new Date().toISOString() }) + .eq("id", supabaseUser.id); + + if (error) { + throw error; + } + + // Refresh profile data + await refreshProfile(); + console.info("[AuthContext] Username updated successfully."); + } catch (error: any) { + console.error("[AuthContext] Error updating username:", error); + setError(error.message || "Failed to update username"); + } finally { + setIsLoading(false); + } + }, [supabaseUser, refreshProfile]); + + // ===================================================================================== + // COMBINED AUTHENTICATION METHODS + // ===================================================================================== + + // Combined logout function that handles both auth types + const logout = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + console.debug("[AuthContext] Logging out..."); + + try { + // Handle Supabase logout if needed + if (supabaseUser) { + const { error } = await supabase.auth.signOut(); + if (error) { + throw error; + } + } + + // Handle Keystore logout + setKeystoreAuthMode('unauthenticated'); + setCurrentUser(null); + setSessionPrivateKey(null); + sessionStorage.removeItem(SESSION_STORAGE_KEY); + + console.info("[AuthContext] User logged out successfully."); + } catch (error: any) { + console.error("[AuthContext] Error logging out:", error); + setError(error.message || "Failed to log out"); + } finally { + setIsLoading(false); + } + }, [supabaseUser]); + + // Modified switchToGuestMode to use Supabase anonymous sign-in + const switchToGuestMode = useCallback((): void => { + console.debug("[AuthContext] Switching to guest mode..."); + + // Use Supabase anonymous sign-in if available + signInAnonymously().catch((error) => { + console.error("[AuthContext] Failed to use Supabase anonymous sign-in, falling back to local guest mode:", error); + + // Fallback to local guest mode + if (keystoreAuthMode === 'keystore') { + setCurrentUser(null); + setSessionPrivateKey(null); + } + setKeystoreAuthMode('guest'); + setError(null); + }); + + console.info("[AuthContext] Switched to guest mode."); + }, [keystoreAuthMode, signInAnonymously]); + + // ===================================================================================== + // CONTEXT PROVIDER + // ===================================================================================== const contextValue: AuthContextType = { + // General auth state authMode, - currentUser, isLoading, error, - setError, // Expose setError - isAuthenticated: authMode === 'keystore' && !!currentUser, - isGuest: authMode === 'guest', + setError, + + // Auth state flags + isAuthenticated, + isGuest, + isKeystoreAuthenticated, + isSupabaseAuthenticated, + + // Keystore-specific properties and methods + currentUser, createAccount, loginWithKeystore, + signData, + + // Supabase-specific properties and methods + supabaseUser, + supabaseSession, + profile, + signInWithGitHub, + signInWithMagicLink, + signInAnonymously, + updateUsername, + refreshProfile, + + // Combined methods logout, switchToGuestMode, - signData, }; return {children}; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 00000000..73e4b206 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,51 @@ +import { createClient } from '@supabase/supabase-js'; + +// Supabase configuration using the provided Project URL and Anon Key +const supabaseUrl = 'https://mchzrmrvgemplhktdwls.supabase.co'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1jaHpybXJ2Z2VtcGxoa3Rkd2xzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDk0NDQ5ODksImV4cCI6MjA2NTAyMDk4OX0.ZTjK209qGwuvrcykuTHK-JsZGXn9NRhmSgVbKjU463Q'; + +// Create a single supabase client for interacting with your database +// The storageKey is set to be specific to this project. +const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + autoRefreshToken: true, + persistSession: true, + storageKey: 'pathToPython-auth-storage', // Unique storage key for this application + }, +}); + +// Handle potential initialization errors +if (!supabase) { + console.error('Failed to initialize Supabase client'); + throw new Error('Supabase client initialization failed'); +} + +// Export the supabase client as a singleton +export default supabase; + +// Type definitions for profile data +export type Profile = { + id: string; + username: string | null; + updated_at: string; + created_at: string; +}; + +// Helper function to check if Supabase is connected +export const checkSupabaseConnection = async () => { + try { + // A simple query to check if the connection is working + // This assumes a 'profiles' table exists, which will be created via SQL later. + const { error } = await supabase.from('profiles').select('count', { count: 'exact' }).limit(0); + + if (error) { + console.error('Supabase connection error:', error); + return false; + } + + return true; + } catch (err) { + console.error('Failed to connect to Supabase:', err); + return false; + } +}; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index d4390fc7..1b5d8b77 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -52,7 +52,18 @@ const TOTAL_LESSONS = allLessonsData.length; const Dashboard: React.FC = () => { const [selectedLesson, setSelectedLesson] = useState(null); const { completedLessons, xp, level, getChapterProgress, isLessonCompleted } = useProgress(); - const { authMode, currentUser, logout, createAccount: upgradeToKeystoreAccount, isLoading: isAuthLoading } = useAuth(); + const { + authMode, + currentUser, + supabaseUser, + profile, + logout, + createAccount: upgradeToKeystoreAccount, + isLoading: isAuthLoading, + isGuest, + isKeystoreAuthenticated, + isSupabaseAuthenticated + } = useAuth(); const [activeAccordionItem, setActiveAccordionItem] = useState( allChaptersData.length > 0 ? `chapter-${allChaptersData[0].id}` : undefined @@ -138,6 +149,21 @@ const Dashboard: React.FC = () => { ); } + // Determine username to display in header + const displayUsername = useMemo(() => { + if (isSupabaseAuthenticated && profile?.username) { + return profile.username; + } + if (isSupabaseAuthenticated && supabaseUser?.email) { + return supabaseUser.email.split('@')[0]; + } + if (isKeystoreAuthenticated && currentUser?.publicKeyHex) { + return `${currentUser.publicKeyHex.substring(0, 6)}...${currentUser.publicKeyHex.slice(-4)}`; + } + return "Guest"; // Should not happen if isGuest is true, but as a fallback + }, [isSupabaseAuthenticated, profile, supabaseUser, isKeystoreAuthenticated, currentUser]); + + return (
@@ -155,7 +181,7 @@ const Dashboard: React.FC = () => {
- {authMode === 'guest' && ( + {isGuest && ( )} - {authMode === 'keystore' && currentUser && ( + {(isKeystoreAuthenticated || isSupabaseAuthenticated) && ( <> - ID: {currentUser.publicKeyHex.substring(0, 6)}...{currentUser.publicKeyHex.slice(-4)} + Welcome, {displayUsername}
- {authMode === 'guest' && ( + {isGuest && ( You are in Guest Mode! From ccb3487f74d506f4eae49f0bec47afecd3403b27 Mon Sep 17 00:00:00 2001 From: "factory-droid[bot]" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 18:45:15 +0000 Subject: [PATCH 2/3] fix: Dynamic redirect URLs for OAuth authentication - Use window.location.href instead of window.location.origin for exact page redirects - Add getURL helper function in supabase.ts for dynamic URL handling - Update AuthContext to use getURL for GitHub and Magic Link redirects - This ensures users return to exactly where they started regardless of hosting location - Works universally for localhost (any port), local HTML files, or deployed websites --- src/components/LoginPage.tsx | 9 +++++---- src/contexts/AuthContext.tsx | 6 +++--- src/lib/supabase.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 70c5119d..0aef88ed 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -8,7 +8,7 @@ * * @project Python Quest - A Gamified Python Learning Platform * @author Factory AI Development Team - * @date June 2, 2025 + * @date June 10, 2025 */ import React, { useState, FormEvent, ChangeEvent } from 'react'; @@ -19,7 +19,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { KeyRound, UserPlus, ShieldCheck, UploadCloud, LogIn, PlayCircle, Loader2, AlertTriangle, Eye, EyeOff, Mail, Github } from 'lucide-react'; import { ThemeProvider } from '@/contexts/ThemeContext'; -import supabase from '@/lib/supabase'; +import supabase from '@/lib/supabase'; // Ensure this import is present const LoginPage: React.FC = () => { const { @@ -73,6 +73,7 @@ const LoginPage: React.FC = () => { setMagicLinkSent(false); }; + const handleCreateAccount = async (e: FormEvent) => { e.preventDefault(); clearLocalErrors(); @@ -140,7 +141,7 @@ const LoginPage: React.FC = () => { const { data, error } = await supabase.auth.signInWithOtp({ email: magicLinkEmail, options: { - emailRedirectTo: window.location.origin, + emailRedirectTo: window.location.href, // Use window.location.href for dynamic redirect } }); @@ -160,7 +161,7 @@ const LoginPage: React.FC = () => { const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'github', options: { - redirectTo: window.location.origin + redirectTo: window.location.href // Use window.location.href for dynamic redirect } }); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index aa393a24..78909d77 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -14,7 +14,7 @@ */ import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; -import supabase from '@/lib/supabase'; // Import Supabase client +import supabase, { getURL } from '@/lib/supabase'; // Import Supabase client and getURL import { Session, User, AuthError } from '@supabase/supabase-js'; // Import Supabase types // ===================================================================================== @@ -562,7 +562,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const { error } = await supabase.auth.signInWithOAuth({ provider: "github", options: { - redirectTo: `${window.location.origin}/`, + redirectTo: getURL(), }, }); @@ -586,7 +586,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const { error } = await supabase.auth.signInWithOtp({ email, options: { - emailRedirectTo: `${window.location.origin}/`, + emailRedirectTo: getURL(), }, }); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 73e4b206..c4eb05d6 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -4,6 +4,34 @@ import { createClient } from '@supabase/supabase-js'; const supabaseUrl = 'https://mchzrmrvgemplhktdwls.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1jaHpybXJ2Z2VtcGxoa3Rkd2xzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDk0NDQ5ODksImV4cCI6MjA2NTAyMDk4OX0.ZTjK209qGwuvrcykuTHK-JsZGXn9NRhmSgVbKjU463Q'; +/** + * Helper function to get the correct redirect URL dynamically + * This will work whether the app is hosted locally, on localhost with any port, or on any domain. + */ +export function getURL() { + // Check for environment variables first (for server-side environments) + let url = + process?.env?.NEXT_PUBLIC_SITE_URL ?? // Next.js specific environment variable + process?.env?.VITE_SITE_URL ?? // Vite specific environment variable + process?.env?.VERCEL_URL ?? // Vercel environment variable + 'http://localhost:8080/'; // Fallback for local development + + // Make sure to include `https://` when not localhost + url = url.includes('http') ? url : `https://${url}`; + + // Make sure to include a trailing `/` if not present + url = url.charAt(url.length - 1) === '/' ? url : `${url}/`; + + // For client-side, use window.location for the most accurate current URL + if (typeof window !== 'undefined') { + // Use window.location.href to get the full current URL including path and query parameters + // This ensures the user returns to the exact page they were on + return window.location.href; + } + + return url; +} + // Create a single supabase client for interacting with your database // The storageKey is set to be specific to this project. const supabase = createClient(supabaseUrl, supabaseAnonKey, { From cb3024ef428e4c424263f2ae87bd9c0a5756f817 Mon Sep 17 00:00:00 2001 From: "factory-droid[bot]" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:42:24 +0000 Subject: [PATCH 3/3] fix: Add universal path handling for all environments - Add getBasePath() helper to detect application base URL dynamically - Configure BrowserRouter with dynamic basename for universal path support - Update URL handling to work with GitHub Pages or any subdirectory host - Support local file usage, offline mode, and any deployment environment - Ensure all relative paths work correctly regardless of hosting location --- src/App.tsx | 3 +- src/lib/supabase.ts | 70 +++++++++++++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 18daf2e9..b10ed0ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import Index from "./pages/Index"; import NotFound from "./pages/NotFound"; +import { getBasePath } from "./lib/supabase"; // Import getBasePath const queryClient = new QueryClient(); @@ -13,7 +14,7 @@ const App = () => ( - + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index c4eb05d6..b47284a4 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -5,33 +5,67 @@ const supabaseUrl = 'https://mchzrmrvgemplhktdwls.supabase.co'; const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1jaHpybXJ2Z2VtcGxoa3Rkd2xzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDk0NDQ5ODksImV4cCI6MjA2NTAyMDk4OX0.ZTjK209qGwuvrcykuTHK-JsZGXn9NRhmSgVbKjU463Q'; /** - * Helper function to get the correct redirect URL dynamically - * This will work whether the app is hosted locally, on localhost with any port, or on any domain. + * Helper function to get the correct absolute redirect URL dynamically. + * This URL is used by OAuth providers (like GitHub) and Supabase to redirect + * the user back to the application after authentication. It must be an absolute URL. + * It will work whether the app is hosted locally, on localhost with any port, or on any domain. */ -export function getURL() { - // Check for environment variables first (for server-side environments) +export function getURL(): string { + // For client-side, use window.location.href for the most accurate current URL. + // This ensures the user returns to the exact page they were on, including path and query parameters. + if (typeof window !== 'undefined') { + return window.location.href; + } + + // Fallback for server-side rendering or build environments. + // Prioritize environment variables, then default to a common local development URL. let url = - process?.env?.NEXT_PUBLIC_SITE_URL ?? // Next.js specific environment variable - process?.env?.VITE_SITE_URL ?? // Vite specific environment variable - process?.env?.VERCEL_URL ?? // Vercel environment variable - 'http://localhost:8080/'; // Fallback for local development + process?.env?.NEXT_PUBLIC_SITE_URL ?? + process?.env?.VITE_SITE_URL ?? + process?.env?.VERCEL_URL ?? + 'http://localhost:8080/'; // Default fallback - // Make sure to include `https://` when not localhost + // Ensure protocol is present url = url.includes('http') ? url : `https://${url}`; - - // Make sure to include a trailing `/` if not present + // Ensure trailing slash for consistency url = url.charAt(url.length - 1) === '/' ? url : `${url}/`; - - // For client-side, use window.location for the most accurate current URL - if (typeof window !== 'undefined') { - // Use window.location.href to get the full current URL including path and query parameters - // This ensures the user returns to the exact page they were on - return window.location.href; - } return url; } +/** + * Helper function to get the base path of the application. + * This is useful for configuring React Router's basename or for constructing + * relative paths to assets, especially when deployed to a subdirectory (e.g., GitHub Pages). + * It attempts to infer the base path from the current URL's pathname. + */ +export function getBasePath(): string { + if (typeof window === 'undefined') { + // In server-side environments, the base path might need to be configured + // via environment variables or build settings. For now, return an empty string + // assuming root deployment or that the build system handles it. + return ''; + } + + const pathname = window.location.pathname; + // Example: If URL is https://user.github.io/repo-name/index.html + // pathname will be /repo-name/index.html + // We want /repo-name/ + const parts = pathname.split('/'); + // Filter out empty strings and 'index.html' + const relevantParts = parts.filter(part => part !== '' && part !== 'index.html'); + + if (relevantParts.length > 0) { + // Assuming the first part after the root is the base path (e.g., 'repo-name') + // This is a common pattern for GitHub Pages where the base is // + return `/${relevantParts[0]}/`; + } + + // If no relevant parts, it's likely served from the root + return '/'; +} + + // Create a single supabase client for interacting with your database // The storageKey is set to be specific to this project. const supabase = createClient(supabaseUrl, supabaseAnonKey, {