From ade0be97f65d6fad42d981682c81bce04d6f1157 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Mon, 1 Jun 2026 02:16:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(auth):=20R4=20auth=20reliability=20?= =?UTF-8?q?=E2=80=94=20fix=20PKCE=20race=20condition=20+=20RequireAuth=20l?= =?UTF-8?q?oading=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/AppRoutes.tsx | 31 +++++++++++++++++++++++++++---- src/contexts/AuthContext.tsx | 13 +++++++++++-- src/pages/auth/loginPage.tsx | 30 +++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 0b2edda..10746fa 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -45,8 +45,6 @@ import { } from './pages/timetable'; const FullContextRoutes: React.FC = () => { - // Only block on Supabase profile being ready — Neo4j contexts initialize in the background. - // Individual pages handle their own Neo4j loading states. const { isInitialized: isUserInitialized } = useUser(); if (!isUserInitialized) { @@ -68,6 +66,31 @@ const FullContextRoutes: React.FC = () => { return ; }; +const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user, isAuthResolving } = useAuth(); + + if (isAuthResolving) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +}; + const AppRoutes: React.FC = () => { const { user, user_role, loading: isAuthLoading } = useAuth(); const location = useLocation(); @@ -136,7 +159,7 @@ const AppRoutes: React.FC = () => { /> {/* Authentication only routes - only render if all contexts are initialized */} - {user && ( + }> {/* Timetable Module Routes */} } /> @@ -167,7 +190,7 @@ const AppRoutes: React.FC = () => { } /> } /> - )} + {/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */} : } /> diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 971fe65..49002ab 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -18,6 +18,7 @@ export interface AuthContextType { signOut: () => Promise; clearError: () => void; bootstrapData: BootstrapResponse | null; + isAuthResolving: boolean; } export const AuthContext = createContext({ @@ -29,7 +30,8 @@ export const AuthContext = createContext({ signIn: async () => {}, signOut: async () => {}, clearError: () => {}, - bootstrapData: null + bootstrapData: null, + isAuthResolving: false }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [bootstrapData, setBootstrapData] = useState(null); + const [isAuthResolving, setIsAuthResolving] = useState(false); const persistSession = useCallback((session: Session | null) => { @@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (event === 'SIGNED_IN') { persistSession(session ?? null); + setIsAuthResolving(!!session?.user); if (session?.user) { try { const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); @@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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); @@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setAccessToken(null); } setLoading(false); + setIsAuthResolving(false); return; } if (event === 'SIGNED_OUT') { persistSession(null); + setIsAuthResolving(false); setUser(null); setUserRole(null); setAccessToken(null); @@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { signIn, signOut, clearError, - bootstrapData + bootstrapData, + isAuthResolving }} > {children} diff --git a/src/pages/auth/loginPage.tsx b/src/pages/auth/loginPage.tsx index 4aa7a82..723544e 100644 --- a/src/pages/auth/loginPage.tsx +++ b/src/pages/auth/loginPage.tsx @@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm'; import { EmailCredentials } from '../../services/auth/authService'; import { logger } from '../../debugConfig'; +const getLoginErrorMessage = (error: unknown): string => { + const status = (error as { status?: number } | null)?.status; + const code = (error as { code?: string } | null)?.code; + + if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') { + return 'Please confirm your email before logging in'; + } + + if (status === 401 || code === 'invalid_grant') { + return 'Invalid email or password'; + } + + return error instanceof Error ? error.message : 'Login failed'; +}; + const LoginPage: React.FC = () => { const navigate = useNavigate(); - const { user, signIn } = useAuth(); + const { user, signIn, isAuthResolving } = useAuth(); const [error, setError] = useState(null); logger.debug('login-page', '🔍 Login page loaded', { - hasUser: !!user + hasUser: !!user, + isAuthResolving }); useEffect(() => { - if (user) { + if (!isAuthResolving && user) { navigate('/dashboard'); } - }, [user, navigate]); + }, [user, isAuthResolving, navigate]); const handleLogin = async (credentials: EmailCredentials) => { try { setError(null); await signIn(credentials.email, credentials.password); - navigate('/dashboard'); } catch (error) { logger.error('login-page', '❌ Login failed', error); - setError(error instanceof Error ? error.message : 'Login failed'); + const message = getLoginErrorMessage(error); + setError(message); throw error; } }; - if (user) { + if (user && !isAuthResolving) { return null; }