235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Session, User } from '@supabase/supabase-js';
|
|
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
|
|
import { logger } from '../debugConfig';
|
|
import { supabase } from '../supabaseClient';
|
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
|
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
|
|
|
|
|
|
export interface AuthContextType {
|
|
user: CCUser | null;
|
|
user_role: string | null;
|
|
accessToken: string | null;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
signIn: (email: string, password: string) => Promise<void>;
|
|
signOut: () => Promise<void>;
|
|
clearError: () => void;
|
|
bootstrapData: BootstrapResponse | null;
|
|
isAuthResolving: boolean;
|
|
}
|
|
|
|
export const AuthContext = createContext<AuthContextType>({
|
|
user: null,
|
|
user_role: null,
|
|
accessToken: null,
|
|
loading: true,
|
|
error: null,
|
|
signIn: async () => {},
|
|
signOut: async () => {},
|
|
clearError: () => {},
|
|
bootstrapData: null,
|
|
isAuthResolving: false
|
|
});
|
|
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const navigate = useNavigate();
|
|
const [user, setUser] = useState<CCUser | null>(null);
|
|
const [user_role, setUserRole] = useState<string | null>(null);
|
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
|
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
|
|
|
|
|
const persistSession = useCallback((session: Session | null) => {
|
|
if (session) {
|
|
storageService.set(StorageKeys.SUPABASE_SESSION, session);
|
|
} else {
|
|
storageService.remove(StorageKeys.SUPABASE_SESSION);
|
|
}
|
|
}, []);
|
|
|
|
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
|
|
if (!supabaseUser) {
|
|
return { user: null, role: null };
|
|
}
|
|
|
|
const metadata = supabaseUser.user_metadata as CCUserMetadata;
|
|
const baseUsername = metadata.username || metadata.preferred_username || metadata.email?.split('@')[0] || supabaseUser.email?.split('@')[0] || 'user';
|
|
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
|
|
const userType = (metadata.user_type || 'email_teacher').trim();
|
|
|
|
const resolvedUser: CCUser = {
|
|
id: supabaseUser.id,
|
|
email: supabaseUser.email,
|
|
user_type: userType,
|
|
username: baseUsername,
|
|
display_name: baseDisplayName,
|
|
school_id: null,
|
|
created_at: supabaseUser.created_at,
|
|
updated_at: supabaseUser.updated_at
|
|
};
|
|
|
|
const resolvedRole = metadata.user_role || userType || null;
|
|
return { user: resolvedUser, role: resolvedRole };
|
|
}, []);
|
|
|
|
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
|
|
if (!token) {
|
|
setBootstrapData(null);
|
|
return null;
|
|
}
|
|
const bootstrap = await fetchBootstrapData(token);
|
|
setBootstrapData(bootstrap);
|
|
return bootstrap;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
|
async (event, session) => {
|
|
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
|
|
|
|
if (event === 'SIGNED_IN') {
|
|
persistSession(session ?? null);
|
|
setIsAuthResolving(!!session?.user);
|
|
if (session?.user) {
|
|
try {
|
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
|
setUser(resolvedUser);
|
|
setUserRole(role);
|
|
setAccessToken(session.access_token ?? null);
|
|
await loadBootstrap(session.access_token);
|
|
} catch (buildError) {
|
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
|
setUser(null);
|
|
setUserRole(null);
|
|
setAccessToken(null);
|
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
|
}
|
|
} else {
|
|
setUser(null);
|
|
setUserRole(null);
|
|
setAccessToken(null);
|
|
}
|
|
setLoading(false);
|
|
setIsAuthResolving(false);
|
|
return;
|
|
}
|
|
|
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
|
persistSession(session ?? null);
|
|
setIsAuthResolving(!!session?.user);
|
|
if (session?.user) {
|
|
try {
|
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
|
setUser(resolvedUser);
|
|
setUserRole(role);
|
|
setAccessToken(session.access_token ?? null);
|
|
await loadBootstrap(session.access_token);
|
|
} catch (buildError) {
|
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
|
setUser(null);
|
|
setUserRole(null);
|
|
setAccessToken(null);
|
|
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
|
|
}
|
|
} else {
|
|
setUser(null);
|
|
setUserRole(null);
|
|
setAccessToken(null);
|
|
}
|
|
setLoading(false);
|
|
setIsAuthResolving(false);
|
|
return;
|
|
}
|
|
|
|
if (event === 'SIGNED_OUT') {
|
|
persistSession(null);
|
|
setIsAuthResolving(false);
|
|
setUser(null);
|
|
setUserRole(null);
|
|
setAccessToken(null);
|
|
setBootstrapData(null);
|
|
invalidateBootstrapCache();
|
|
setLoading(false);
|
|
}
|
|
}
|
|
);
|
|
|
|
return () => subscription.unsubscribe();
|
|
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
|
|
|
|
const signIn = async (email: string, password: string) => {
|
|
try {
|
|
setLoading(true);
|
|
const { data, error: signInError } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password
|
|
});
|
|
|
|
if (signInError) throw signInError;
|
|
|
|
if (data.session) {
|
|
persistSession(data.session);
|
|
}
|
|
|
|
if (data.user) {
|
|
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
|
|
setUser(resolvedUser);
|
|
setUserRole(role);
|
|
setAccessToken(data.session?.access_token ?? null);
|
|
await loadBootstrap(data.session?.access_token ?? null);
|
|
}
|
|
} catch (error) {
|
|
logger.error('auth-context', '❌ Sign in failed', { error });
|
|
setError(error instanceof Error ? error : new Error('Sign in failed'));
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const signOut = async () => {
|
|
try {
|
|
setLoading(true);
|
|
await authService.logout();
|
|
persistSession(null);
|
|
setUser(null);
|
|
navigate('/');
|
|
} catch (error) {
|
|
logger.error('auth-context', '❌ Sign out failed', { error });
|
|
setError(error instanceof Error ? error : new Error('Sign out failed'));
|
|
throw error;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const clearError = () => setError(null);
|
|
|
|
return (
|
|
<AuthContext.Provider
|
|
value={{
|
|
user,
|
|
user_role,
|
|
accessToken,
|
|
loading,
|
|
error,
|
|
signIn,
|
|
signOut,
|
|
clearError,
|
|
bootstrapData,
|
|
isAuthResolving
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|
|
|
|
export const useAuth = () => useContext(AuthContext);
|