- Remove NeoUserProvider + NeoInstituteProvider from App.tsx startup chain - Strip user_db_name/school_db_name from CCUser; add school_id (Phase B wires it to Supabase) - Remove DatabaseNameService from AuthContext and UserContext - Remove provisionUser() call from login path; API endpoint preserved for Phase B decision - Simplify UserContext.resolveProfile: fast-path JWT metadata then background Supabase fetch - Replace user.user_db_name reads in singlePlayerPage + snapshotService with null-safe guards - Add useDeviceContext hook (desktop/tablet/phone/iwb, persists to localStorage) App now loads to dashboard without any Neo4j dependency at startup. Canvas opens to blank TLDraw state; Phase B rebuilds navigation on Supabase. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
import { Session, User } from '@supabase/supabase-js';
|
|
import { supabase } from '../supabaseClient';
|
|
import { logger } from '../debugConfig';
|
|
import { CCUser, CCUserMetadata } from '../services/auth/authService';
|
|
import { UserPreferences } from '../services/auth/profileService';
|
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
|
|
|
export interface UserContextType {
|
|
user: CCUser | null;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
profile: CCUser | null;
|
|
preferences: UserPreferences;
|
|
isMobile: boolean;
|
|
isInitialized: boolean;
|
|
updateProfile: (updates: Partial<CCUser>) => Promise<void>;
|
|
updatePreferences: (updates: Partial<UserPreferences>) => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
export const UserContext = createContext<UserContextType>({
|
|
user: null,
|
|
loading: true,
|
|
error: null,
|
|
profile: null,
|
|
preferences: {},
|
|
isMobile: false,
|
|
isInitialized: false,
|
|
updateProfile: async () => {},
|
|
updatePreferences: async () => {},
|
|
clearError: () => {}
|
|
});
|
|
|
|
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
|
const [user] = useState<CCUser | null>(null);
|
|
const [profile, setProfile] = useState<CCUser | null>(null);
|
|
const [preferences, setPreferences] = useState<UserPreferences>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [isMobile] = useState(window.innerWidth <= 768);
|
|
const mountedRef = React.useRef(true);
|
|
|
|
// Use the main Supabase client for all operations to ensure proper session persistence
|
|
// This avoids the "Multiple GoTrueClient instances" warning and ensures session restoration works
|
|
|
|
const resolveProfile = useCallback(async (supabaseUser?: User | null, session?: Session | null) => {
|
|
// Prevent duplicate work when we already have the same user resolved
|
|
if (mountedRef.current && isInitialized) {
|
|
const resolvedUserId = profile?.id;
|
|
const incomingUserId = supabaseUser?.id ?? session?.user?.id ?? null;
|
|
|
|
if (!incomingUserId && !supabaseUser) {
|
|
logger.debug('user-context', '⚠️ Profile already initialized for guest session, skipping resolution');
|
|
return;
|
|
}
|
|
|
|
if (incomingUserId && resolvedUserId && resolvedUserId === incomingUserId) {
|
|
logger.debug('user-context', '⚠️ Profile already initialized for current user, skipping resolution');
|
|
return;
|
|
}
|
|
}
|
|
|
|
let userInfo: User | null = null; // Declare at function scope
|
|
try {
|
|
logger.debug('user-context', '🔄 Resolving user profile', {
|
|
hasSupabaseUser: !!supabaseUser,
|
|
isInitialized
|
|
});
|
|
logger.debug('user-context', '🔧 Step 1: Starting profile resolution...');
|
|
// Don't set loading to true immediately - let the UI show progress naturally
|
|
let authSession = session;
|
|
userInfo = supabaseUser ?? null;
|
|
|
|
logger.debug('user-context', '🔧 Step 2: Getting auth session...');
|
|
if (!authSession) {
|
|
const { data } = await supabase.auth.getSession();
|
|
authSession = data.session;
|
|
logger.debug('user-context', '🔧 Step 2a: Got session from supabase', {
|
|
hasSession: !!authSession
|
|
});
|
|
}
|
|
|
|
logger.debug('user-context', '🔧 Step 3: Getting user info...');
|
|
if (!userInfo) {
|
|
const { data } = await supabase.auth.getUser();
|
|
userInfo = data.user;
|
|
logger.debug('user-context', '🔧 Step 3a: Got user from supabase', {
|
|
hasUser: !!userInfo
|
|
});
|
|
}
|
|
|
|
if (!userInfo) {
|
|
logger.debug('user-context', '⚠️ No user info available - clearing profile');
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
setProfile(null);
|
|
setPreferences({});
|
|
setError(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
logger.debug('user-context', '🔧 Step 4: User info available, proceeding...', {
|
|
userId: userInfo.id,
|
|
email: userInfo.email
|
|
});
|
|
|
|
let profileRow: Record<string, unknown> | null = null;
|
|
|
|
// Fast-path: build profile from auth metadata immediately (no spinner on refresh).
|
|
const fastMetadata = userInfo.user_metadata as CCUserMetadata;
|
|
const fastProfile: CCUser = {
|
|
id: userInfo.id,
|
|
email: userInfo.email,
|
|
user_type: fastMetadata?.user_type || '',
|
|
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
|
|
display_name: String(fastMetadata?.display_name || ''),
|
|
school_id: null,
|
|
created_at: userInfo.created_at,
|
|
updated_at: userInfo.updated_at
|
|
};
|
|
if (mountedRef.current && !isInitialized) {
|
|
setProfile(fastProfile);
|
|
setLoading(false);
|
|
setIsInitialized(true);
|
|
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
|
|
}
|
|
|
|
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
|
|
// No setLoading toggles — spinner is already cleared above.
|
|
const { data, error } = await supabase
|
|
.from('profiles')
|
|
.select('*')
|
|
.eq('id', userInfo.id)
|
|
.single();
|
|
|
|
if (error && error.code !== 'PGRST116') {
|
|
logger.warn('user-context', '⚠️ Profiles query failed, using fast-path profile', {
|
|
error: error.message,
|
|
code: error.code
|
|
});
|
|
profileRow = null;
|
|
} else if (data) {
|
|
profileRow = data;
|
|
logger.debug('user-context', '✅ Supabase profile fetched', {
|
|
userId: data.id,
|
|
userDbName: data.user_db_name
|
|
});
|
|
} else {
|
|
profileRow = null;
|
|
}
|
|
|
|
logger.debug('user-context', '🔧 Step 6: Processing profile data...', {
|
|
userId: userInfo.id,
|
|
hasProfileRow: !!profileRow,
|
|
hasUserDb: !!profileRow?.user_db_name,
|
|
hasSchoolDb: !!profileRow?.school_db_name
|
|
});
|
|
|
|
|
|
const metadata = userInfo.user_metadata as CCUserMetadata;
|
|
const userProfile: CCUser = {
|
|
id: userInfo.id,
|
|
email: userInfo.email,
|
|
user_type: metadata.user_type || '',
|
|
username: metadata.username || '',
|
|
display_name: String(metadata.display_name || ''),
|
|
school_id: (profileRow?.school_id as string) ?? null,
|
|
created_at: userInfo.created_at,
|
|
updated_at: userInfo.updated_at
|
|
};
|
|
|
|
if (!mountedRef.current) {
|
|
logger.debug('user-context', '❌ Component unmounted during profile creation');
|
|
return;
|
|
}
|
|
|
|
logger.debug('user-context', '🔧 Setting profile and preferences...', {
|
|
profileId: userProfile.id
|
|
});
|
|
|
|
setProfile(userProfile);
|
|
setPreferences({
|
|
theme: (profileRow?.theme && typeof profileRow.theme === 'string' && ['system', 'light', 'dark'].includes(profileRow.theme)) ? profileRow.theme as 'system' | 'light' | 'dark' : 'system',
|
|
notifications: Boolean(profileRow?.notifications_enabled)
|
|
});
|
|
|
|
logger.debug('user-context', '✅ User profile loaded', {
|
|
userId: userProfile.id,
|
|
userType: userProfile.user_type,
|
|
username: userProfile.username
|
|
});
|
|
setError(null);
|
|
} catch (error) {
|
|
logger.error('user-context', '❌ Failed to load user profile', { error });
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
logger.error('user-context', '❌ Resolving user profile failed', {
|
|
message: error instanceof Error ? error.message : String(error)
|
|
});
|
|
// Create fallback profile even when errors occur
|
|
logger.debug('user-context', '🔧 Creating fallback profile due to error...', {
|
|
userId: userInfo?.id,
|
|
email: userInfo?.email
|
|
});
|
|
|
|
if (userInfo) {
|
|
const metadata = userInfo.user_metadata as CCUserMetadata;
|
|
const fallbackProfile: CCUser = {
|
|
id: userInfo.id,
|
|
email: userInfo.email,
|
|
user_type: metadata?.user_type || 'email_teacher',
|
|
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
|
|
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
|
|
school_id: null,
|
|
created_at: userInfo.created_at,
|
|
updated_at: userInfo.updated_at
|
|
};
|
|
setProfile(fallbackProfile);
|
|
logger.debug('user-context', '✅ Fallback profile created', {
|
|
userId: fallbackProfile.id,
|
|
userType: fallbackProfile.user_type
|
|
});
|
|
} else {
|
|
setProfile(null);
|
|
}
|
|
|
|
setPreferences({});
|
|
setError(error instanceof Error ? error : new Error('Failed to load user profile'));
|
|
setLoading(false); // Ensure loading is cleared on error
|
|
} finally {
|
|
logger.debug('user-context', '🔧 Finalizing user context initialization...', {
|
|
isMounted: mountedRef.current
|
|
});
|
|
|
|
if (mountedRef.current) {
|
|
// Loading state is already managed above, just log completion
|
|
logger.debug('user-context', '✅ User context initialization complete');
|
|
}
|
|
|
|
logger.debug('user-context', '🔧 Step 10: Setting isInitialized to true');
|
|
setIsInitialized(true);
|
|
logger.debug('user-context', '✅ User context initialized flag set - initialization complete!', {
|
|
isInitialized: true,
|
|
profileId: profile?.id,
|
|
userType: profile?.user_type
|
|
});
|
|
}
|
|
}, [profile?.id, profile?.user_type, isInitialized]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
|
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
|
|
logger.debug('user-context', '🔄 Auth state change', {
|
|
event,
|
|
hasSession: !!session,
|
|
hasUser: !!session?.user
|
|
});
|
|
|
|
switch (event) {
|
|
case 'SIGNED_OUT':
|
|
setLoading(false);
|
|
setProfile(null);
|
|
setPreferences({});
|
|
setIsInitialized(true);
|
|
setError(null);
|
|
break;
|
|
case 'SIGNED_IN':
|
|
case 'TOKEN_REFRESHED':
|
|
case 'INITIAL_SESSION':
|
|
await resolveProfile(session?.user ?? null, session ?? null);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [resolveProfile]);
|
|
|
|
const updateProfile = async (updates: Partial<CCUser>) => {
|
|
if (!user?.id || !profile) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const { error } = await supabase
|
|
.from('profiles')
|
|
.update({
|
|
...updates,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', user.id);
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
|
|
setProfile(prev => prev ? { ...prev, ...updates } : null);
|
|
logger.info('user-context', '✅ Profile updated successfully');
|
|
} catch (error) {
|
|
logger.error('user-context', '❌ Failed to update profile', { error });
|
|
setError(error instanceof Error ? error : new Error('Failed to update profile'));
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const updatePreferences = async (updates: Partial<UserPreferences>) => {
|
|
if (!user?.id) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const newPreferences = { ...preferences, ...updates };
|
|
setPreferences(newPreferences);
|
|
|
|
const { error } = await supabase
|
|
.from('profiles')
|
|
.update({
|
|
preferences: newPreferences,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', user.id);
|
|
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
|
|
logger.info('user-context', '✅ Preferences updated successfully');
|
|
} catch (error) {
|
|
logger.error('user-context', '❌ Failed to update preferences', { error });
|
|
setError(error instanceof Error ? error : new Error('Failed to update preferences'));
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Store profile in localStorage whenever it changes
|
|
useEffect(() => {
|
|
if (profile) {
|
|
storageService.set(StorageKeys.USER, profile);
|
|
logger.debug('user-context', '💾 Stored user profile in localStorage', {
|
|
userId: profile.id,
|
|
userType: profile.user_type
|
|
});
|
|
} else {
|
|
storageService.remove(StorageKeys.USER);
|
|
logger.debug('user-context', '🗑️ Removed user profile from localStorage');
|
|
}
|
|
}, [profile]);
|
|
|
|
return (
|
|
<UserContext.Provider
|
|
value={{
|
|
user: profile,
|
|
loading,
|
|
error,
|
|
profile,
|
|
preferences,
|
|
isMobile,
|
|
isInitialized,
|
|
updateProfile,
|
|
updatePreferences,
|
|
clearError: () => setError(null)
|
|
}}
|
|
>
|
|
{children}
|
|
</UserContext.Provider>
|
|
);
|
|
}
|
|
|
|
export const useUser = () => useContext(UserContext);
|