merge: R4 auth reliability — PKCE race fix + RequireAuth loading skeleton

This commit is contained in:
CC Worker 2026-06-01 02:38:54 +00:00
commit c216df8021
3 changed files with 61 additions and 13 deletions

View File

@ -45,8 +45,6 @@ import {
} from './pages/timetable'; } from './pages/timetable';
const FullContextRoutes: React.FC = () => { 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(); const { isInitialized: isUserInitialized } = useUser();
if (!isUserInitialized) { if (!isUserInitialized) {
@ -68,6 +66,31 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />; return <Outlet />;
}; };
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isAuthResolving } = useAuth();
if (isAuthResolving) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<CircularProgress />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const AppRoutes: React.FC = () => { const AppRoutes: React.FC = () => {
const { user, user_role, loading: isAuthLoading } = useAuth(); const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation(); const location = useLocation();
@ -136,7 +159,7 @@ const AppRoutes: React.FC = () => {
/> />
{/* Authentication only routes - only render if all contexts are initialized */} {/* Authentication only routes - only render if all contexts are initialized */}
{user && ( <RequireAuth>
<Route element={<FullContextRoutes />}> <Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */} {/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} /> <Route path="/timetable" element={<TimetablePage />} />
@ -167,7 +190,7 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} /> <Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
)} </RequireAuth>
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */} {/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} /> <Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />

View File

@ -18,6 +18,7 @@ export interface AuthContextType {
signOut: () => Promise<void>; signOut: () => Promise<void>;
clearError: () => void; clearError: () => void;
bootstrapData: BootstrapResponse | null; bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
} }
export const AuthContext = createContext<AuthContextType>({ export const AuthContext = createContext<AuthContextType>({
@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
signIn: async () => {}, signIn: async () => {},
signOut: async () => {}, signOut: async () => {},
clearError: () => {}, clearError: () => {},
bootstrapData: null bootstrapData: null,
isAuthResolving: false
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null); const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const [isAuthResolving, setIsAuthResolving] = useState(false);
const persistSession = useCallback((session: Session | null) => { const persistSession = useCallback((session: Session | null) => {
@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (event === 'SIGNED_IN') { if (event === 'SIGNED_IN') {
persistSession(session ?? null); persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) { if (session?.user) {
try { try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null); setAccessToken(null);
} }
setLoading(false); setLoading(false);
setIsAuthResolving(false);
return; return;
} }
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') { if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null); persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) { if (session?.user) {
try { try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null); setAccessToken(null);
} }
setLoading(false); setLoading(false);
setIsAuthResolving(false);
return; return;
} }
if (event === 'SIGNED_OUT') { if (event === 'SIGNED_OUT') {
persistSession(null); persistSession(null);
setIsAuthResolving(false);
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null); setAccessToken(null);
@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signIn, signIn,
signOut, signOut,
clearError, clearError,
bootstrapData bootstrapData,
isAuthResolving
}} }}
> >
{children} {children}

View File

@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService'; import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig'; 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 LoginPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, signIn } = useAuth(); const { user, signIn, isAuthResolving } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', { logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user hasUser: !!user,
isAuthResolving
}); });
useEffect(() => { useEffect(() => {
if (user) { if (!isAuthResolving && user) {
navigate('/dashboard'); navigate('/dashboard');
} }
}, [user, navigate]); }, [user, isAuthResolving, navigate]);
const handleLogin = async (credentials: EmailCredentials) => { const handleLogin = async (credentials: EmailCredentials) => {
try { try {
setError(null); setError(null);
await signIn(credentials.email, credentials.password); await signIn(credentials.email, credentials.password);
navigate('/dashboard');
} catch (error) { } catch (error) {
logger.error('login-page', '❌ Login failed', error); logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed'); const message = getLoginErrorMessage(error);
setError(message);
throw error; throw error;
} }
}; };
if (user) { if (user && !isAuthResolving) {
return null; return null;
} }